From 2a191944fb0222e72a366cacc30adb512d91d92d Mon Sep 17 00:00:00 2001
From: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Date: Fri, 21 Nov 2025 13:33:37 -0500
Subject: [PATCH 1/6] feat: add musd conversion flow (#23060)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds the first iteration of the mUSD conversion flow. This PR includes
the core flow that we will iterate on.
## **Changelog**
CHANGELOG entry: add mUSD conversion core flow
## **Related issues**
Fixes:
- [MUSD-64: Mobile Proof of Concept using Transactions
Confirmations](https://consensyssoftware.atlassian.net/browse/MUSD-64)
- [MUSD-69: Use relay for quotes and execution in the Mobile one click
proof of
concept](https://consensyssoftware.atlassian.net/browse/MUSD-69)
- [MUSD-42: Merge mobile mUSD conversion to
production](https://consensyssoftware.atlassian.net/browse/MUSD-42)
- [MUSD-86: As a user I want to see information about the relay quote
when converting to mUSD so that I know how much money I will spend on
fees and gas](https://consensyssoftware.atlassian.net/browse/MUSD-86)
## **Manual testing steps**
```gherkin
Feature: mUSD Token Conversion
Scenario: user converts stablecoin to mUSD on Ethereum mainnet (happy path)
Given user has USDC, USDT, or DAI in their wallet on a supported chain
When user clicks the "Convert" button next to a supported mUSD conversion stablecoin
Then user sees the mUSD conversion confirmation screen
And user can select from available stablecoins in the PayWith modal
And user can input desired conversion amount
And user sees Relay quotes with fees and estimated time
And user completes the conversion flow successfully
Scenario: user views unsupported token
Given user has a non-convertible token in their wallet
When user views their token list
Then user sees the existing "Earn X.X%" CTA
And user does NOT see the mUSD conversion button
```
## **Screenshots/Recordings**
### **Before**
### **After**
https://github.com/user-attachments/assets/be636076-79bd-4b6d-818a-6f6f5b707b06
## **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]
> Adds mUSD conversion flow with Convert CTA, confirmations, global
status toasts, feature flags, and localization.
>
> - **Earn/mUSD**:
> - Add `mUSD` conversion flow with `Convert` CTA in `StakeButton` and
token list for allowlisted stablecoins.
> - Introduce global `EarnTransactionMonitor` and
`useMusdConversionStatus` to show in-progress/success/failure toasts.
> - Add `MusdConversionInfo` screen and wire into redesigned
confirmations stack.
> - **Confirmations**:
> - Define `MUSD_CONVERSION_TRANSACTION_TYPE`; hide footer by default;
add custom button label and fees tooltip; include in
redesigned/full-screen types.
> - Update transaction parsing/labels to recognize mUSD conversion.
> - **Feature Flags/Selectors**:
> - Add `MM_MUSD_CONVERSION_FLOW_ENABLED` and convertible tokens
allowlist (remote/local) with symbol→address conversion.
> - **Constants/Utils**:
> - mUSD token constants, chain/token allowlists, and
`isMusdConversionPaymentToken` helper.
> - **Navigation**:
> - Register redesigned confirmations route in Earn stack; mount
`EarnTransactionMonitor` in main nav.
> - **Localization**:
> - Add strings for mUSD conversion (CTA, toasts, fees, review titles).
> - **CI/Env**:
> - Expose env/Bitrise flags for mUSD conversion; sample allowlist var.
> - **Tests**:
> - Comprehensive unit tests for hooks, selectors, utils, components
(CTA rendering, navigation, error cases).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8a6f35df4c7461cd439be29f78cdb30591549dd1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.js.env.example | 6 +
app/components/Nav/Main/index.js | 2 +
.../EarnTransactionMonitor.test.tsx | 36 ++
.../components/EarnTransactionMonitor.tsx | 18 +
app/components/UI/Earn/constants/musd.ts | 59 ++
.../UI/Earn/hooks/useEarnToasts.test.tsx | 244 ++++++++
.../UI/Earn/hooks/useEarnToasts.tsx | 167 ++++++
.../UI/Earn/hooks/useMusdConversion.test.ts | 526 +++++++++++++++++
.../UI/Earn/hooks/useMusdConversion.ts | 229 ++++++++
.../hooks/useMusdConversionStatus.test.ts | 551 ++++++++++++++++++
.../UI/Earn/hooks/useMusdConversionStatus.ts | 96 +++
app/components/UI/Earn/routes/index.tsx | 9 +
.../Earn/selectors/featureFlags/index.test.ts | 200 +++++++
.../UI/Earn/selectors/featureFlags/index.ts | 77 +++
app/components/UI/Earn/utils/musd.test.ts | 283 +++++++++
app/components/UI/Earn/utils/musd.ts | 87 +++
.../StakeButton/StakeButton.test.tsx | 211 ++++++-
.../UI/Stake/components/StakeButton/index.tsx | 104 +++-
.../TokenListItem/TokenListItemBip44.test.tsx | 51 +-
.../TokenListItem/TokenListItemBip44.tsx | 48 +-
.../TokenList/TokenListItem/index.test.tsx | 68 ++-
.../Tokens/TokenList/TokenListItem/index.tsx | 47 +-
.../components/footer/footer.tsx | 2 +
.../components/info-root/info-root.test.tsx | 21 +
.../components/info-root/info-root.tsx | 9 +
.../custom-amount-info/custom-amount-info.tsx | 5 +
.../info/musd-conversion-info/index.ts | 1 +
.../musd-conversion-info.test.tsx | 292 ++++++++++
.../musd-conversion-info.tsx | 72 +++
.../rows/bridge-fee-row/bridge-fee-row.tsx | 5 +
.../confirmations/constants/confirmations.ts | 6 +-
app/util/transactions/index.js | 9 +
bitrise.yml | 3 +
locales/languages/en.json | 19 +-
34 files changed, 3515 insertions(+), 48 deletions(-)
create mode 100644 app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx
create mode 100644 app/components/UI/Earn/components/EarnTransactionMonitor.tsx
create mode 100644 app/components/UI/Earn/constants/musd.ts
create mode 100644 app/components/UI/Earn/hooks/useEarnToasts.test.tsx
create mode 100644 app/components/UI/Earn/hooks/useEarnToasts.tsx
create mode 100644 app/components/UI/Earn/hooks/useMusdConversion.test.ts
create mode 100644 app/components/UI/Earn/hooks/useMusdConversion.ts
create mode 100644 app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts
create mode 100644 app/components/UI/Earn/hooks/useMusdConversionStatus.ts
create mode 100644 app/components/UI/Earn/utils/musd.test.ts
create mode 100644 app/components/UI/Earn/utils/musd.ts
create mode 100644 app/components/Views/confirmations/components/info/musd-conversion-info/index.ts
create mode 100644 app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx
create mode 100644 app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx
diff --git a/.js.env.example b/.js.env.example
index a64ab593fdc..98c48ef4e1d 100644
--- a/.js.env.example
+++ b/.js.env.example
@@ -112,6 +112,12 @@ export MM_STABLECOIN_LENDING_UI_ENABLED_REDESIGNED="true"
## Pooled-Staking
export MM_POOLED_STAKING_ENABLED="true"
export MM_POOLED_STAKING_SERVICE_INTERRUPTION_BANNER_ENABLED="true"
+# mUSD
+export MM_MUSD_CONVERSION_FLOW_ENABLED="false"
+# Allowlist of convertible tokens by chain
+# IMPORTANT: Must use SINGLE QUOTES to preserve JSON format
+# Example: MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST='{"0x1":["USDC","USDT"],"0xa4b1":["USDC","DAI"]}'
+export MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST=''
# Activates remote feature flag override mode.
# Remote feature flag values won't be updated,
diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js
index d4c3e108751..cb6668d2b18 100644
--- a/app/components/Nav/Main/index.js
+++ b/app/components/Nav/Main/index.js
@@ -37,6 +37,7 @@ import ProtectYourWalletModal from '../../UI/ProtectYourWalletModal';
import MainNavigator from './MainNavigator';
import { query } from '@metamask/controller-utils';
import SwapsLiveness from '../../UI/Swaps/SwapsLiveness';
+import EarnTransactionMonitor from '../../UI/Earn/components/EarnTransactionMonitor';
import {
setInfuraAvailabilityBlocked,
@@ -451,6 +452,7 @@ const Main = (props) => {
+
{renderDeprecatedNetworkAlert(
props.chainId,
props.backUpSeedphraseVisible,
diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx
new file mode 100644
index 00000000000..6a95cf819ed
--- /dev/null
+++ b/app/components/UI/Earn/components/EarnTransactionMonitor.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import EarnTransactionMonitor from './EarnTransactionMonitor';
+import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus';
+
+jest.mock('../hooks/useMusdConversionStatus');
+
+describe('EarnTransactionMonitor', () => {
+ const mockUseMusdConversionStatus = jest.mocked(useMusdConversionStatus);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ const result = render();
+
+ expect(result).toBeDefined();
+ });
+
+ it('calls useMusdConversionStatus hook', () => {
+ render();
+
+ expect(mockUseMusdConversionStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns null', () => {
+ const { toJSON } = render();
+
+ expect(toJSON()).toBeNull();
+ });
+});
diff --git a/app/components/UI/Earn/components/EarnTransactionMonitor.tsx b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx
new file mode 100644
index 00000000000..3fbaa375b90
--- /dev/null
+++ b/app/components/UI/Earn/components/EarnTransactionMonitor.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { useMusdConversionStatus } from '../hooks/useMusdConversionStatus';
+
+/**
+ * EarnTransactionMonitor - Mounts global transaction monitoring hooks for Earn features.
+ *
+ * This component acts as a mount point for persistent transaction monitoring hooks,
+ * allowing them to remain active even when navigating away from Earn screens.
+ */
+const EarnTransactionMonitor: React.FC = () => {
+ // Enable mUSD conversion status monitoring and toasts
+ useMusdConversionStatus();
+
+ // This component doesn't render anything
+ return null;
+};
+
+export default EarnTransactionMonitor;
diff --git a/app/components/UI/Earn/constants/musd.ts b/app/components/UI/Earn/constants/musd.ts
new file mode 100644
index 00000000000..d19310c1089
--- /dev/null
+++ b/app/components/UI/Earn/constants/musd.ts
@@ -0,0 +1,59 @@
+/**
+ * mUSD Conversion Constants for Earn namespace
+ */
+
+import { CHAIN_IDS, TransactionType } from '@metamask/transaction-controller';
+import { Hex } from '@metamask/utils';
+import { NETWORKS_CHAIN_ID } from '../../../../constants/network';
+
+export const MUSD_TOKEN_MAINNET = {
+ address: '0xaca92e438df0b2401ff60da7e4337b687a2435da',
+ symbol: 'MUSD',
+ name: 'MUSD',
+ decimals: 6,
+ chainId: CHAIN_IDS.MAINNET,
+} as const;
+
+export const MUSD_CURRENCY = 'MUSD';
+
+// mUSD token address on Ethereum mainnet (6 decimals)
+export const MUSD_ADDRESS_ETHEREUM =
+ '0xaca92e438df0b2401ff60da7e4337b687a2435da';
+
+// Ethereum mainnet chain ID
+export const ETHEREUM_MAINNET_CHAIN_ID = '0x1';
+
+export const STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN: Record<
+ Hex,
+ Record
+> = {
+ [NETWORKS_CHAIN_ID.MAINNET]: {
+ USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ USDT: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ DAI: '0x6b175474e89094c44da98b954eedeac495271d0f',
+ },
+ // Temp: Uncomment once we support Linea -> Linea quotes
+ // [NETWORKS_CHAIN_ID.LINEA_MAINNET]: {
+ // USDC: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff',
+ // USDT: '0xa219439258ca9da29e9cc4ce5596924745e12b93',
+ // },
+ // [NETWORKS_CHAIN_ID.BSC]: {
+ // USDC: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d',
+ // USDT: '0x55d398326f99059ff775485246999027b3197955',
+ // },
+};
+
+export const CONVERTIBLE_STABLECOINS_BY_CHAIN: Record = (() => {
+ const result: Record = {};
+ for (const [chainId, symbolMap] of Object.entries(
+ STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN,
+ )) {
+ result[chainId as Hex] = Object.values(symbolMap);
+ }
+ return result;
+})();
+
+// TODO: Remove this once we add to TransactionType. Requires updating transaction-controller package.
+// Similar to a swap except that output token is predetermined (e.g. mUSD) and the user cannot change it.
+export const MUSD_CONVERSION_TRANSACTION_TYPE =
+ 'mUSDConversion' as TransactionType;
diff --git a/app/components/UI/Earn/hooks/useEarnToasts.test.tsx b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx
new file mode 100644
index 00000000000..621d27a05cd
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useEarnToasts.test.tsx
@@ -0,0 +1,244 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { notificationAsync, NotificationFeedbackType } from 'expo-haptics';
+import useEarnToasts from './useEarnToasts';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+
+jest.mock('expo-haptics');
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => {
+ if (key === 'earn.musd_conversion.toasts.in_progress') {
+ return `Converting to mUSD`;
+ }
+ if (key === 'earn.musd_conversion.toasts.success') {
+ return `Converted to mUSD`;
+ }
+ if (key === 'earn.musd_conversion.toasts.failed') {
+ return `Failed to convert to mUSD`;
+ }
+ return key;
+ }),
+}));
+
+const mockTheme = {
+ colors: {
+ accent01: {
+ dark: '#accent01-dark',
+ light: '#accent01-light',
+ },
+ accent03: {
+ dark: '#accent03-dark',
+ normal: '#accent03-normal',
+ },
+ accent04: {
+ dark: '#accent04-dark',
+ normal: '#accent04-normal',
+ },
+ },
+};
+
+jest.mock('../../../../util/theme', () => ({
+ useAppThemeFromContext: jest.fn(() => mockTheme),
+}));
+
+describe('useEarnToasts', () => {
+ const mockShowToast = jest.fn();
+ const mockCloseToast = jest.fn();
+ const mockToastRef = {
+ current: {
+ showToast: mockShowToast,
+ closeToast: mockCloseToast,
+ },
+ };
+
+ const mockNotificationAsync = jest.mocked(notificationAsync);
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('showToast', () => {
+ it('calls toastRef.current.showToast with toast options', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const testConfig = {
+ ...result.current.EarnToastOptions.mUsdConversion.success,
+ };
+
+ result.current.showToast(testConfig);
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ }),
+ );
+ });
+
+ it('triggers haptics with correct type', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const testConfig = {
+ ...result.current.EarnToastOptions.mUsdConversion.success,
+ };
+
+ result.current.showToast(testConfig);
+
+ expect(mockNotificationAsync).toHaveBeenCalledTimes(1);
+ expect(mockNotificationAsync).toHaveBeenCalledWith(
+ NotificationFeedbackType.Success,
+ );
+ });
+
+ it('excludes hapticsType from toast options passed to toastRef', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const testConfig = {
+ ...result.current.EarnToastOptions.mUsdConversion.inProgress,
+ };
+
+ result.current.showToast(testConfig);
+
+ const callArgs = mockShowToast.mock.calls[0][0];
+
+ expect(callArgs).not.toHaveProperty('hapticsType');
+ });
+ });
+
+ describe('EarnToastOptions structure', () => {
+ it('includes mUsdConversion with inProgress, success, and failed options', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ expect(result.current.EarnToastOptions.mUsdConversion).toBeDefined();
+ expect(
+ result.current.EarnToastOptions.mUsdConversion.inProgress,
+ ).toBeDefined();
+ expect(
+ result.current.EarnToastOptions.mUsdConversion.success,
+ ).toBeDefined();
+ expect(
+ result.current.EarnToastOptions.mUsdConversion.failed,
+ ).toBeDefined();
+ });
+
+ it('configures success toast with correct properties', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const successToast =
+ result.current.EarnToastOptions.mUsdConversion.success;
+
+ expect(successToast.variant).toBe(ToastVariants.Icon);
+ expect(successToast.iconName).toBe(IconName.CheckBold);
+ expect(successToast.iconColor).toBeDefined();
+ expect(successToast.backgroundColor).toBeDefined();
+ expect(successToast.hapticsType).toBe(NotificationFeedbackType.Success);
+ });
+
+ it('configures inProgress toast with correct properties', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const inProgressToast =
+ result.current.EarnToastOptions.mUsdConversion.inProgress;
+
+ expect(inProgressToast.variant).toBe(ToastVariants.Icon);
+ expect(inProgressToast.iconName).toBe(IconName.Loading);
+ expect(inProgressToast.iconColor).toBeDefined();
+ expect(inProgressToast.backgroundColor).toBeDefined();
+ expect(inProgressToast.hapticsType).toBe(
+ NotificationFeedbackType.Warning,
+ );
+ });
+
+ it('configures failed toast with correct properties', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const failedToast = result.current.EarnToastOptions.mUsdConversion.failed;
+
+ expect(failedToast.variant).toBe(ToastVariants.Icon);
+ expect(failedToast.iconName).toBe(IconName.Warning);
+ expect(failedToast.iconColor).toBeDefined();
+ expect(failedToast.backgroundColor).toBeDefined();
+ expect(failedToast.hapticsType).toBe(NotificationFeedbackType.Error);
+ });
+ });
+
+ describe('spinner for inProgress toast', () => {
+ it('includes startAccessory with Spinner for inProgress toast', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const inProgressToast =
+ result.current.EarnToastOptions.mUsdConversion.inProgress;
+
+ expect(inProgressToast.startAccessory).toBeDefined();
+ });
+ });
+
+ describe('toast labels', () => {
+ it('includes tokenSymbol in inProgress label', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const inProgressToast =
+ result.current.EarnToastOptions.mUsdConversion.inProgress;
+
+ expect(inProgressToast.labelOptions).toBeDefined();
+ expect(Array.isArray(inProgressToast.labelOptions)).toBe(true);
+ expect(inProgressToast.labelOptions).toHaveLength(1);
+ });
+
+ it('includes tokenSymbol in success label', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const successToast =
+ result.current.EarnToastOptions.mUsdConversion.success;
+
+ expect(successToast.labelOptions).toBeDefined();
+ expect(Array.isArray(successToast.labelOptions)).toBe(true);
+ expect(successToast.labelOptions).toHaveLength(1);
+ });
+
+ it('includes tokenSymbol in failed label', () => {
+ const { result } = renderHook(() => useEarnToasts(), { wrapper });
+
+ const failedToast = result.current.EarnToastOptions.mUsdConversion.failed;
+
+ expect(failedToast.labelOptions).toBeDefined();
+ expect(Array.isArray(failedToast.labelOptions)).toBe(true);
+ expect(failedToast.labelOptions).toHaveLength(1);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles missing toastRef gracefully', () => {
+ const emptyWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useEarnToasts(), {
+ wrapper: emptyWrapper,
+ });
+
+ const testConfig = {
+ ...result.current.EarnToastOptions.mUsdConversion.success,
+ };
+
+ expect(() => result.current.showToast(testConfig)).not.toThrow();
+
+ expect(mockNotificationAsync).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/components/UI/Earn/hooks/useEarnToasts.tsx b/app/components/UI/Earn/hooks/useEarnToasts.tsx
new file mode 100644
index 00000000000..749a46f45e2
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useEarnToasts.tsx
@@ -0,0 +1,167 @@
+import {
+ IconColor as ReactNativeDsIconColor,
+ IconSize as ReactNativeDsIconSize,
+} from '@metamask/design-system-react-native';
+import { Spinner } from '@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs';
+import { notificationAsync, NotificationFeedbackType } from 'expo-haptics';
+import React, { useCallback, useContext, useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { strings } from '../../../../../locales/i18n';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import {
+ ToastOptions,
+ ToastVariants,
+} from '../../../../component-library/components/Toast/Toast.types';
+import { useAppThemeFromContext } from '../../../../util/theme';
+
+export type EarnToastOptions = Omit<
+ Extract,
+ 'labelOptions'
+> & {
+ hapticsType: NotificationFeedbackType;
+ // Overwriting ToastOptions.labelOptions to also support ReactNode since this works.
+ labelOptions?: {
+ label: string | React.ReactNode;
+ isBold?: boolean;
+ }[];
+};
+
+export interface EarnToastOptionsConfig {
+ mUsdConversion: {
+ inProgress: EarnToastOptions;
+ success: EarnToastOptions;
+ failed: EarnToastOptions;
+ };
+}
+
+const getEarnToastLabels = (
+ primary: string | React.ReactNode,
+ secondary?: string | React.ReactNode,
+) => {
+ const labels = [
+ {
+ label: primary,
+ isBold: true,
+ },
+ ];
+
+ if (secondary) {
+ labels.push(
+ {
+ label: '\n',
+ isBold: false,
+ },
+ {
+ label: secondary,
+ isBold: false,
+ },
+ );
+ }
+
+ return labels;
+};
+
+const EARN_TOASTS_DEFAULT_OPTIONS: Partial = {
+ hasNoTimeout: false,
+};
+
+const toastStyles = StyleSheet.create({
+ spinnerContainer: {
+ paddingRight: 12,
+ alignContent: 'center',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+const useEarnToasts = (): {
+ showToast: (config: EarnToastOptions) => void;
+ EarnToastOptions: EarnToastOptionsConfig;
+} => {
+ const { toastRef } = useContext(ToastContext);
+ const theme = useAppThemeFromContext();
+
+ const earnBaseToastOptions: Record = useMemo(
+ () => ({
+ success: {
+ ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ iconColor: theme.colors.accent03.dark,
+ backgroundColor: theme.colors.accent03.normal,
+ hapticsType: NotificationFeedbackType.Success,
+ },
+ // Intentional duplication for now to avoid coupling with success options.
+ inProgress: {
+ ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.Loading,
+ iconColor: theme.colors.accent04.dark,
+ backgroundColor: theme.colors.accent04.normal,
+ hapticsType: NotificationFeedbackType.Warning,
+ startAccessory: (
+
+
+
+ ),
+ },
+ error: {
+ ...(EARN_TOASTS_DEFAULT_OPTIONS as EarnToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.Warning,
+ iconColor: theme.colors.accent01.dark,
+ backgroundColor: theme.colors.accent01.light,
+ hapticsType: NotificationFeedbackType.Error,
+ },
+ }),
+ [theme],
+ );
+
+ const showToast = useCallback(
+ (config: EarnToastOptions) => {
+ const { hapticsType, ...toastOptions } = config;
+ toastRef?.current?.showToast(toastOptions as ToastOptions);
+ notificationAsync(hapticsType);
+ },
+ [toastRef],
+ );
+
+ // Centralized toast options for Earn
+ const EarnToastOptions: EarnToastOptionsConfig = useMemo(
+ () => ({
+ mUsdConversion: {
+ inProgress: {
+ ...earnBaseToastOptions.inProgress,
+ labelOptions: getEarnToastLabels(
+ strings('earn.musd_conversion.toasts.in_progress'),
+ ),
+ },
+ success: {
+ ...earnBaseToastOptions.success,
+ labelOptions: getEarnToastLabels(
+ strings('earn.musd_conversion.toasts.success'),
+ ),
+ },
+ failed: {
+ ...earnBaseToastOptions.error,
+ labelOptions: getEarnToastLabels(
+ strings('earn.musd_conversion.toasts.failed'),
+ ),
+ },
+ },
+ }),
+ [
+ earnBaseToastOptions.error,
+ earnBaseToastOptions.inProgress,
+ earnBaseToastOptions.success,
+ ],
+ );
+
+ return { showToast, EarnToastOptions };
+};
+
+export default useEarnToasts;
diff --git a/app/components/UI/Earn/hooks/useMusdConversion.test.ts b/app/components/UI/Earn/hooks/useMusdConversion.test.ts
new file mode 100644
index 00000000000..2e91d08f675
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useMusdConversion.test.ts
@@ -0,0 +1,526 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import {
+ useMusdConversion,
+ areValidAllowedPaymentTokens,
+} from './useMusdConversion';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
+import { generateTransferData } from '../../../../util/transactions';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../constants/musd';
+import { MMM_ORIGIN } from '../../../Views/confirmations/constants/confirmations';
+import Routes from '../../../../constants/navigation/Routes';
+import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component';
+import { Hex } from '@metamask/utils';
+import { useNavigation } from '@react-navigation/native';
+import { useSelector } from 'react-redux';
+
+// Mock all external dependencies
+jest.mock('../../../../core/Engine');
+jest.mock('../../../../util/Logger');
+jest.mock('../../../../util/transactions');
+jest.mock('@react-navigation/native');
+jest.mock('react-redux');
+jest.mock(
+ '../../../Views/confirmations/components/confirm/confirm-component',
+ () => ({
+ ConfirmationLoader: {
+ CustomAmount: 'customAmount',
+ },
+ }),
+);
+
+const mockNavigation = {
+ navigate: jest.fn(),
+ dispatch: jest.fn(),
+ reset: jest.fn(),
+ goBack: jest.fn(),
+ isFocused: jest.fn(),
+ canGoBack: jest.fn(),
+ getState: jest.fn(),
+ getParent: jest.fn(),
+ setParams: jest.fn(),
+ setOptions: jest.fn(),
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ getId: jest.fn(),
+ dangerouslyGetParent: jest.fn(),
+ dangerouslyGetState: jest.fn(),
+};
+
+const mockNetworkController = {
+ findNetworkClientIdByChainId: jest.fn(),
+};
+
+const mockTransactionController = {
+ addTransaction: jest.fn(),
+};
+
+const mockUseNavigation = useNavigation as jest.MockedFunction<
+ typeof useNavigation
+>;
+const mockUseSelector = useSelector as jest.MockedFunction;
+
+describe('useMusdConversion', () => {
+ const mockSelectedAccount = {
+ address: '0x123456789abcdef' as Hex,
+ id: 'account-1',
+ metadata: {},
+ options: {},
+ methods: [],
+ type: 'eip155:eoa',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseNavigation.mockReturnValue(mockNavigation);
+
+ Object.defineProperty(Engine, 'context', {
+ value: {
+ NetworkController: mockNetworkController,
+ TransactionController: mockTransactionController,
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ (generateTransferData as jest.Mock).mockReturnValue('0xmockedTransferData');
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('initiateConversion', () => {
+ const mockConfig = {
+ outputToken: {
+ address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'MUSD',
+ name: 'MUSD',
+ decimals: 6,
+ },
+ preferredPaymentToken: {
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex,
+ chainId: '0x1' as Hex,
+ },
+ };
+
+ it('navigates with correct params', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await result.current.initiateConversion(mockConfig);
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.EARN.ROOT, {
+ screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
+ params: {
+ loader: ConfirmationLoader.CustomAmount,
+ preferredPaymentToken: mockConfig.preferredPaymentToken,
+ outputToken: mockConfig.outputToken,
+ allowedPaymentTokens: undefined,
+ },
+ });
+ });
+
+ it('creates transaction with correct data structure', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await result.current.initiateConversion(mockConfig);
+
+ expect(mockTransactionController.addTransaction).toHaveBeenCalledWith(
+ {
+ to: mockConfig.outputToken.address,
+ from: mockSelectedAccount.address,
+ data: '0xmockedTransferData',
+ value: '0x0',
+ chainId: mockConfig.outputToken.chainId,
+ },
+ {
+ networkClientId: 'mainnet',
+ origin: MMM_ORIGIN,
+ type: MUSD_CONVERSION_TRANSACTION_TYPE,
+ nestedTransactions: [
+ {
+ to: mockConfig.outputToken.address,
+ data: '0xmockedTransferData',
+ value: '0x0',
+ },
+ ],
+ },
+ );
+ });
+
+ it('includes nestedTransactions array structure for Relay', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await result.current.initiateConversion(mockConfig);
+
+ const addTransactionCall =
+ mockTransactionController.addTransaction.mock.calls[0];
+ const options = addTransactionCall[1];
+
+ expect(options.nestedTransactions).toBeDefined();
+ expect(Array.isArray(options.nestedTransactions)).toBe(true);
+ expect(options.nestedTransactions).toHaveLength(1);
+ expect(options.nestedTransactions[0]).toEqual({
+ to: mockConfig.outputToken.address,
+ data: '0xmockedTransferData',
+ value: '0x0',
+ });
+ });
+
+ it('throws error when selectedAddress is missing', async () => {
+ const mockSelectorFn = jest.fn(() => null);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(null);
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await act(async () => {
+ await expect(
+ result.current.initiateConversion(mockConfig),
+ ).rejects.toThrow('No account selected');
+ });
+
+ expect(Logger.error).toHaveBeenCalled();
+ });
+
+ it('throws error when networkClientId not found', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ undefined,
+ );
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await act(async () => {
+ await expect(
+ result.current.initiateConversion(mockConfig),
+ ).rejects.toThrow('Network client not found for chain ID');
+ });
+
+ expect(Logger.error).toHaveBeenCalled();
+ });
+
+ it('throws error when outputToken is missing', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const invalidConfig = {
+ ...mockConfig,
+ outputToken: undefined,
+ };
+
+ await act(async () => {
+ await expect(
+ // @ts-expect-error - Intentionally testing invalid config with missing outputToken
+ result.current.initiateConversion(invalidConfig),
+ ).rejects.toThrow(
+ 'Output token and preferred payment token are required',
+ );
+ });
+ });
+
+ it('throws error when preferredPaymentToken is missing', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const invalidConfig = {
+ ...mockConfig,
+ preferredPaymentToken: undefined,
+ };
+
+ await act(async () => {
+ await expect(
+ // @ts-expect-error - Intentionally testing invalid config with missing preferredPaymentToken
+ result.current.initiateConversion(invalidConfig),
+ ).rejects.toThrow(
+ 'Output token and preferred payment token are required',
+ );
+ });
+ });
+
+ it('sets error state when transaction creation fails', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockRejectedValue(
+ new Error('Transaction failed'),
+ );
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ await act(async () => {
+ await expect(
+ result.current.initiateConversion(mockConfig),
+ ).rejects.toThrow('Transaction failed');
+ });
+
+ expect(result.current.error).toBe('Transaction failed');
+ expect(Logger.error).toHaveBeenCalled();
+ expect(mockNavigation.goBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses custom navigationStack when provided', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const configWithCustomStack = {
+ ...mockConfig,
+ navigationStack: 'CustomStack',
+ };
+
+ await result.current.initiateConversion(configWithCustomStack);
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith(
+ 'CustomStack',
+ expect.anything(),
+ );
+ });
+
+ it('includes allowedPaymentTokens in navigation params when provided', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const allowedTokens: Record = {
+ '0x1': ['0xabc' as Hex],
+ };
+
+ const configWithAllowedTokens = {
+ ...mockConfig,
+ allowedPaymentTokens: allowedTokens,
+ };
+
+ await result.current.initiateConversion(configWithAllowedTokens);
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.EARN.ROOT, {
+ screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
+ params: expect.objectContaining({
+ allowedPaymentTokens: allowedTokens,
+ }),
+ });
+ });
+
+ it('returns transaction ID on success', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+ mockTransactionController.addTransaction.mockResolvedValue({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const transactionId = await result.current.initiateConversion(mockConfig);
+
+ expect(transactionId).toBe('tx-123');
+ });
+ });
+
+ describe('error state', () => {
+ it('initializes with null error', () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ expect(result.current.error).toBeNull();
+ });
+
+ it('clears error on successful conversion attempt', async () => {
+ const mockSelectorFn = jest.fn(() => mockSelectedAccount);
+ mockUseSelector.mockReturnValue(mockSelectorFn);
+ mockSelectorFn.mockReturnValue(mockSelectedAccount);
+
+ mockNetworkController.findNetworkClientIdByChainId.mockReturnValue(
+ 'mainnet',
+ );
+
+ const { result } = renderHook(() => useMusdConversion());
+
+ const mockConfig = {
+ outputToken: {
+ address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'MUSD',
+ name: 'MUSD',
+ decimals: 6,
+ },
+ preferredPaymentToken: {
+ address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex,
+ chainId: '0x1' as Hex,
+ },
+ };
+
+ mockTransactionController.addTransaction.mockRejectedValueOnce(
+ new Error('Transaction failed'),
+ );
+
+ await act(async () => {
+ await expect(
+ result.current.initiateConversion(mockConfig),
+ ).rejects.toThrow('Transaction failed');
+ });
+
+ expect(result.current.error).toBe('Transaction failed');
+
+ mockTransactionController.addTransaction.mockResolvedValueOnce({
+ transactionMeta: { id: 'tx-123' },
+ });
+
+ await act(async () => {
+ await result.current.initiateConversion(mockConfig);
+ });
+
+ expect(result.current.error).toBeNull();
+ });
+ });
+});
+
+describe('areValidAllowedPaymentTokens', () => {
+ it('returns true for valid Record', () => {
+ const validInput: Record = {
+ '0x1': ['0xabc' as Hex, '0xdef' as Hex],
+ '0x2': ['0x123' as Hex],
+ };
+
+ const result = areValidAllowedPaymentTokens(validInput);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false for null', () => {
+ const result = areValidAllowedPaymentTokens(null);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for undefined', () => {
+ const result = areValidAllowedPaymentTokens(undefined);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for arrays', () => {
+ const result = areValidAllowedPaymentTokens(['0x1', '0x2']);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when keys are not hex strings', () => {
+ const invalidInput = {
+ notHex: ['0xabc' as Hex],
+ };
+
+ const result = areValidAllowedPaymentTokens(invalidInput);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when values are not arrays', () => {
+ const invalidInput = {
+ '0x1': '0xabc',
+ };
+
+ const result = areValidAllowedPaymentTokens(invalidInput);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false when array elements are not hex strings', () => {
+ const invalidInput = {
+ '0x1': ['notHex'],
+ };
+
+ const result = areValidAllowedPaymentTokens(invalidInput);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns true for empty object', () => {
+ const result = areValidAllowedPaymentTokens({});
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true for object with empty arrays', () => {
+ const validInput: Record = {
+ '0x1': [],
+ };
+
+ const result = areValidAllowedPaymentTokens(validInput);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/app/components/UI/Earn/hooks/useMusdConversion.ts b/app/components/UI/Earn/hooks/useMusdConversion.ts
new file mode 100644
index 00000000000..2c3e5c6a751
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useMusdConversion.ts
@@ -0,0 +1,229 @@
+import { Hex, isHexString } from '@metamask/utils';
+import { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
+import { generateTransferData } from '../../../../util/transactions';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../constants/musd';
+import { MMM_ORIGIN } from '../../../Views/confirmations/constants/confirmations';
+import { useNavigation } from '@react-navigation/native';
+import Routes from '../../../../constants/navigation/Routes';
+import { ConfirmationLoader } from '../../../Views/confirmations/components/confirm/confirm-component';
+import { EVM_SCOPE } from '../constants/networks';
+import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts';
+
+/**
+ * Type guard to validate allowedPaymentTokens structure.
+ * Checks if the value is a valid Record mapping.
+ * Validates that both keys (chain IDs) and values (token addresses) are hex strings.
+ *
+ * @param value - Value to validate
+ * @returns true if valid, false otherwise
+ */
+export const areValidAllowedPaymentTokens = (
+ value: unknown,
+): value is Record => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false;
+ }
+
+ return Object.entries(value).every(
+ ([key, val]) =>
+ isHexString(key) &&
+ Array.isArray(val) &&
+ val.every((addr) => isHexString(addr)),
+ );
+};
+
+/**
+ * Configuration for mUSD conversion
+ */
+export interface MusdConversionConfig {
+ /**
+ * The mUSD token to convert to
+ */
+ outputToken: {
+ address: Hex;
+ chainId: Hex;
+ symbol: string;
+ name: string;
+ decimals: number;
+ };
+ /**
+ * The payment token to prefill in the confirmation screen
+ */
+ preferredPaymentToken: {
+ address: Hex;
+ chainId: Hex;
+ };
+ /**
+ * Optional allowlist of payment tokens that can be used to pay for the conversion, organized by chain ID.
+ * Maps chain IDs to arrays of allowed token addresses.
+ * If not provided, all tokens will be available for selection.
+ */
+ allowedPaymentTokens?: Record;
+ /**
+ * Optional navigation stack to use (defaults to Routes.EARN.ROOT)
+ */
+ navigationStack?: string;
+}
+
+/**
+ * Hook for initiating mUSD conversion flow using MetaMask Pay.
+ *
+ * **EVM-Only**: This hook only supports EVM-compatible chains. It uses ERC-20
+ * transfer encoding and MetaMask Pay's Relay integration, which are specific to
+ * EVM networks. For non-EVM chains (Bitcoin, Solana, Tron), use alternative flows.
+ *
+ * This hook handles both transaction creation and navigation to the confirmation screen.
+ *
+ * @example
+ * const { initiateConversion } = useMusdConversion();
+ *
+ * await initiateConversion({
+ * outputToken: {
+ * address: MUSD_ADDRESS_ETHEREUM,
+ * chainId: ETHEREUM_MAINNET_CHAIN_ID,
+ * symbol: 'mUSD',
+ * name: 'mUSD',
+ * decimals: 6,
+ * },
+ * preferredPaymentToken: {
+ * address: USDC_ADDRESS_ARBITRUM,
+ * chainId: NETWORKS_CHAIN_ID.ARBITRUM,
+ * },
+ * });
+ */
+export const useMusdConversion = () => {
+ const [error, setError] = useState(null);
+ const navigation = useNavigation();
+
+ const selectedAccount = useSelector(selectSelectedInternalAccountByScope)(
+ EVM_SCOPE,
+ );
+
+ const selectedAddress = selectedAccount?.address;
+
+ /**
+ * Creates a placeholder transaction and navigating to confirmation.
+ * Navigation happens immediately, then transaction creation happens in background.
+ */
+ const initiateConversion = useCallback(
+ async (config: MusdConversionConfig): Promise => {
+ const {
+ outputToken,
+ preferredPaymentToken,
+ navigationStack = Routes.EARN.ROOT,
+ } = config;
+
+ try {
+ setError(null);
+
+ if (!outputToken || !preferredPaymentToken) {
+ throw new Error(
+ 'Output token and preferred payment token are required',
+ );
+ }
+
+ if (!selectedAddress) {
+ throw new Error('No account selected');
+ }
+
+ const { NetworkController } = Engine.context;
+ const networkClientId = NetworkController.findNetworkClientIdByChainId(
+ outputToken.chainId,
+ );
+
+ if (!networkClientId) {
+ throw new Error(
+ `Network client not found for chain ID: ${outputToken.chainId}`,
+ );
+ }
+
+ /**
+ * Navigate to the confirmation screen immediately for better UX,
+ * since there can be a delay between the user's button press and
+ * transaction creation in the background.
+ */
+ navigation.navigate(navigationStack, {
+ screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
+ params: {
+ loader: ConfirmationLoader.CustomAmount,
+ preferredPaymentToken,
+ outputToken: {
+ address: outputToken.address,
+ chainId: outputToken.chainId,
+ symbol: outputToken.symbol,
+ name: outputToken.name,
+ decimals: outputToken.decimals,
+ },
+ allowedPaymentTokens: config.allowedPaymentTokens,
+ },
+ });
+
+ const ZERO_HEX_VALUE = '0x0';
+
+ /**
+ * Create minimal transfer data with amount = 0
+ * The actual amount will be set by the user on the confirmation screen
+ */
+ const transferData = generateTransferData('transfer', {
+ toAddress: selectedAddress,
+ amount: ZERO_HEX_VALUE,
+ });
+
+ const { TransactionController } = Engine.context;
+
+ const { transactionMeta } = await TransactionController.addTransaction(
+ {
+ to: outputToken.address,
+ from: selectedAddress,
+ data: transferData,
+ value: ZERO_HEX_VALUE,
+ chainId: outputToken.chainId,
+ },
+ {
+ networkClientId,
+ origin: MMM_ORIGIN,
+ type: MUSD_CONVERSION_TRANSACTION_TYPE,
+ // Important: Nested transaction is required for Relay to work. This will be fixed in a future iteration.
+ nestedTransactions: [
+ {
+ to: outputToken.address,
+ data: transferData as Hex,
+ value: ZERO_HEX_VALUE,
+ },
+ ],
+ },
+ );
+
+ const newTransactionId = transactionMeta.id;
+
+ return newTransactionId;
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error
+ ? err.message
+ : 'Failed to create mUSD conversion transaction';
+
+ Logger.error(
+ err as Error,
+ '[mUSD Conversion] Failed to create conversion transaction',
+ );
+
+ setError(errorMessage);
+
+ // Prevent user from being stuck on confirmation screen without a transaction.
+ navigation.goBack();
+
+ throw err;
+ }
+ },
+ [navigation, selectedAddress],
+ );
+
+ return {
+ initiateConversion,
+ error,
+ };
+};
diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts
new file mode 100644
index 00000000000..4f0cbf703ef
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.test.ts
@@ -0,0 +1,551 @@
+import {
+ TransactionMeta,
+ TransactionStatus,
+} from '@metamask/transaction-controller';
+import { renderHook } from '@testing-library/react-hooks';
+import Engine from '../../../../core/Engine';
+import { useMusdConversionStatus } from './useMusdConversionStatus';
+import useEarnToasts, { EarnToastOptionsConfig } from './useEarnToasts';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../constants/musd';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { NotificationFeedbackType } from 'expo-haptics';
+
+// Mock all external dependencies
+jest.mock('../../../../core/Engine');
+jest.mock('./useEarnToasts');
+
+type TransactionStatusUpdatedHandler = (event: {
+ transactionMeta: TransactionMeta;
+}) => void;
+
+const mockSubscribe = jest.fn<
+ void,
+ [string, TransactionStatusUpdatedHandler]
+>();
+const mockUnsubscribe = jest.fn<
+ void,
+ [string, TransactionStatusUpdatedHandler]
+>();
+const mockUseEarnToasts = jest.mocked(useEarnToasts);
+
+Object.defineProperty(Engine, 'controllerMessenger', {
+ value: {
+ subscribe: mockSubscribe,
+ unsubscribe: mockUnsubscribe,
+ },
+ writable: true,
+ configurable: true,
+});
+
+describe('useMusdConversionStatus', () => {
+ const mockShowToast = jest.fn();
+ const mockEarnToastOptions: EarnToastOptionsConfig = {
+ mUsdConversion: {
+ inProgress: {
+ variant: ToastVariants.Icon,
+ iconName: IconName.Loading,
+ hasNoTimeout: false,
+ iconColor: '#000000',
+ backgroundColor: '#FFFFFF',
+ hapticsType: NotificationFeedbackType.Success,
+ labelOptions: [{ label: 'In Progress', isBold: true }],
+ },
+ success: {
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ hasNoTimeout: false,
+ iconColor: '#000000',
+ backgroundColor: '#FFFFFF',
+ hapticsType: NotificationFeedbackType.Success,
+ labelOptions: [{ label: 'Success', isBold: true }],
+ },
+ failed: {
+ variant: ToastVariants.Icon,
+ iconName: IconName.Danger,
+ hasNoTimeout: false,
+ iconColor: '#000000',
+ backgroundColor: '#FFFFFF',
+ hapticsType: NotificationFeedbackType.Error,
+ labelOptions: [{ label: 'Failed', isBold: true }],
+ },
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ mockUseEarnToasts.mockReturnValue({
+ showToast: mockShowToast,
+ EarnToastOptions: mockEarnToastOptions,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+ });
+
+ const createTransactionMeta = (
+ status: TransactionStatus,
+ transactionId = 'test-transaction-1',
+ type = MUSD_CONVERSION_TRANSACTION_TYPE,
+ ): TransactionMeta => ({
+ id: transactionId,
+ status,
+ type,
+ chainId: '0x1',
+ networkClientId: 'mainnet',
+ time: Date.now(),
+ txParams: {
+ from: '0x123',
+ to: '0x456',
+ },
+ });
+
+ const getSubscribedHandler = (): TransactionStatusUpdatedHandler => {
+ const subscribeCalls = mockSubscribe.mock.calls;
+ const lastCall = subscribeCalls.at(-1);
+ if (!lastCall) {
+ throw new Error('No subscription found');
+ }
+ return lastCall[1];
+ };
+
+ describe('subscription lifecycle', () => {
+ it('subscribes to TransactionController:transactionStatusUpdated on mount', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ expect(mockSubscribe).toHaveBeenCalledWith(
+ 'TransactionController:transactionStatusUpdated',
+ expect.any(Function),
+ );
+ });
+
+ it('unsubscribes from TransactionController:transactionStatusUpdated on unmount', () => {
+ const { unmount } = renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+
+ unmount();
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ expect(mockUnsubscribe).toHaveBeenCalledWith(
+ 'TransactionController:transactionStatusUpdated',
+ handler,
+ );
+ });
+ });
+
+ describe('submitted transaction status', () => {
+ it('shows in-progress toast when transaction status is submitted', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.inProgress,
+ );
+ });
+
+ it('prevents duplicate in-progress toast for same transaction', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ );
+
+ handler({ transactionMeta });
+ handler({ transactionMeta });
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('confirmed transaction status', () => {
+ it('shows success toast when transaction status is confirmed', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.success,
+ );
+ });
+
+ it('prevents duplicate success toast for same transaction', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ );
+
+ handler({ transactionMeta });
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ });
+
+ it('cleans up toast tracking entries after 5 seconds for confirmed status', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionId = 'test-transaction-1';
+ const submittedMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ transactionId,
+ );
+ const confirmedMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ transactionId,
+ );
+
+ handler({ transactionMeta: submittedMeta });
+ handler({ transactionMeta: confirmedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(2);
+
+ jest.advanceTimersByTime(5000);
+
+ // After cleanup, should be able to show toasts again for same transaction
+ handler({ transactionMeta: submittedMeta });
+ handler({ transactionMeta: confirmedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(4);
+ });
+ });
+
+ describe('failed transaction status', () => {
+ it('shows failed toast when transaction status is failed', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(TransactionStatus.failed);
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.failed,
+ );
+ });
+
+ it('prevents duplicate failed toast for same transaction', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(TransactionStatus.failed);
+
+ handler({ transactionMeta });
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ });
+
+ it('cleans up toast tracking entries after 5 seconds for failed status', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionId = 'test-transaction-2';
+ const submittedMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ transactionId,
+ );
+ const failedMeta = createTransactionMeta(
+ TransactionStatus.failed,
+ transactionId,
+ );
+
+ handler({ transactionMeta: submittedMeta });
+ handler({ transactionMeta: failedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(2);
+
+ jest.advanceTimersByTime(5000);
+
+ // After cleanup, should be able to show toasts again for same transaction
+ handler({ transactionMeta: submittedMeta });
+ handler({ transactionMeta: failedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(4);
+ });
+ });
+
+ describe('transaction flow from submitted to final status', () => {
+ it('shows both in-progress and success toasts for transaction flow', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionId = 'test-transaction-3';
+ const submittedMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ transactionId,
+ );
+ const confirmedMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ transactionId,
+ );
+
+ handler({ transactionMeta: submittedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.inProgress,
+ );
+
+ handler({ transactionMeta: confirmedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(2);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.success,
+ );
+ });
+
+ it('shows both in-progress and failed toasts for transaction flow', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionId = 'test-transaction-4';
+ const submittedMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ transactionId,
+ );
+ const failedMeta = createTransactionMeta(
+ TransactionStatus.failed,
+ transactionId,
+ );
+
+ handler({ transactionMeta: submittedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.inProgress,
+ );
+
+ handler({ transactionMeta: failedMeta });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(2);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.failed,
+ );
+ });
+ });
+
+ describe('non-mUSD conversion transactions', () => {
+ it('ignores transaction when type is not mUSD conversion', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ 'test-transaction-5',
+ 'contractInteraction' as typeof MUSD_CONVERSION_TRANSACTION_TYPE,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('ignores transaction when type is swap', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ 'test-transaction-6',
+ 'swap' as typeof MUSD_CONVERSION_TRANSACTION_TYPE,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('ignores transaction when type is simpleSend', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.failed,
+ 'test-transaction-7',
+ 'simpleSend' as typeof MUSD_CONVERSION_TRANSACTION_TYPE,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('other transaction statuses', () => {
+ it('ignores transaction when status is unapproved', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.unapproved,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('ignores transaction when status is approved', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(TransactionStatus.approved);
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('ignores transaction when status is signed', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(TransactionStatus.signed);
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('ignores transaction when status is rejected', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(TransactionStatus.rejected);
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('multiple concurrent transactions', () => {
+ it('tracks and shows toasts for different transactions independently', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transaction1Submitted = createTransactionMeta(
+ TransactionStatus.submitted,
+ 'transaction-1',
+ );
+ const transaction2Submitted = createTransactionMeta(
+ TransactionStatus.submitted,
+ 'transaction-2',
+ );
+ const transaction1Confirmed = createTransactionMeta(
+ TransactionStatus.confirmed,
+ 'transaction-1',
+ );
+ const transaction2Failed = createTransactionMeta(
+ TransactionStatus.failed,
+ 'transaction-2',
+ );
+
+ handler({ transactionMeta: transaction1Submitted });
+ handler({ transactionMeta: transaction2Submitted });
+ handler({ transactionMeta: transaction1Confirmed });
+ handler({ transactionMeta: transaction2Failed });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(4);
+ expect(mockShowToast).toHaveBeenNthCalledWith(
+ 1,
+ mockEarnToastOptions.mUsdConversion.inProgress,
+ );
+ expect(mockShowToast).toHaveBeenNthCalledWith(
+ 2,
+ mockEarnToastOptions.mUsdConversion.inProgress,
+ );
+ expect(mockShowToast).toHaveBeenNthCalledWith(
+ 3,
+ mockEarnToastOptions.mUsdConversion.success,
+ );
+ expect(mockShowToast).toHaveBeenNthCalledWith(
+ 4,
+ mockEarnToastOptions.mUsdConversion.failed,
+ );
+ });
+
+ it('cleans up only entries for specific transaction after timeout', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transaction1Confirmed = createTransactionMeta(
+ TransactionStatus.confirmed,
+ 'transaction-1',
+ );
+ const transaction2Confirmed = createTransactionMeta(
+ TransactionStatus.confirmed,
+ 'transaction-2',
+ );
+
+ handler({ transactionMeta: transaction1Confirmed });
+ handler({ transactionMeta: transaction2Confirmed });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(2);
+
+ jest.advanceTimersByTime(5000);
+
+ // Both transactions should be cleaned up after 5 seconds
+ handler({ transactionMeta: transaction1Confirmed });
+ handler({ transactionMeta: transaction2Confirmed });
+
+ expect(mockShowToast).toHaveBeenCalledTimes(4);
+ });
+ });
+
+ describe('hook dependencies', () => {
+ it('uses showToast function from useEarnToasts hook', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ expect(mockUseEarnToasts).toHaveBeenCalledTimes(1);
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.submitted,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+
+ it('uses EarnToastOptions from useEarnToasts hook', () => {
+ renderHook(() => useMusdConversionStatus());
+
+ const handler = getSubscribedHandler();
+ const transactionMeta = createTransactionMeta(
+ TransactionStatus.confirmed,
+ );
+
+ handler({ transactionMeta });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ mockEarnToastOptions.mUsdConversion.success,
+ );
+ });
+ });
+});
diff --git a/app/components/UI/Earn/hooks/useMusdConversionStatus.ts b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts
new file mode 100644
index 00000000000..af5ae338224
--- /dev/null
+++ b/app/components/UI/Earn/hooks/useMusdConversionStatus.ts
@@ -0,0 +1,96 @@
+import {
+ TransactionMeta,
+ TransactionStatus,
+} from '@metamask/transaction-controller';
+import { useEffect, useRef } from 'react';
+import Engine from '../../../../core/Engine';
+import useEarnToasts from './useEarnToasts';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../constants/musd';
+
+/**
+ * Hook to monitor mUSD conversion transaction status and show appropriate toasts
+ *
+ * This hook:
+ * 1. Subscribes to TransactionController:transactionStatusUpdated events
+ * 2. Filters for mUSD conversion transactions (type === 'musdConversion')
+ * 3. Shows toasts based on transaction status:
+ * - submitted → in-progress toast
+ * - confirmed → success toast
+ * - failed → failed toast
+ * 4. Tracks shown toasts to prevent duplicates
+ *
+ * This hook should be mounted globally to ensure toasts are shown even when
+ * navigating away from the conversion screen.
+ */
+export const useMusdConversionStatus = () => {
+ const { showToast, EarnToastOptions } = useEarnToasts();
+
+ const shownToastsRef = useRef>(new Set());
+
+ useEffect(() => {
+ const handleTransactionStatusUpdated = ({
+ transactionMeta,
+ }: {
+ transactionMeta: TransactionMeta;
+ }) => {
+ if (transactionMeta.type !== MUSD_CONVERSION_TRANSACTION_TYPE) {
+ return;
+ }
+
+ const { id: transactionId, status } = transactionMeta;
+
+ const toastKey = `${transactionId}-${status}`;
+
+ if (shownToastsRef.current.has(toastKey)) {
+ return;
+ }
+
+ switch (status) {
+ case TransactionStatus.submitted:
+ showToast(EarnToastOptions.mUsdConversion.inProgress);
+ shownToastsRef.current.add(toastKey);
+ break;
+ case TransactionStatus.confirmed:
+ showToast(EarnToastOptions.mUsdConversion.success);
+ shownToastsRef.current.add(toastKey);
+ // Clean up entries for this transaction after final status
+ setTimeout(() => {
+ shownToastsRef.current.delete(
+ `${transactionId}-${TransactionStatus.submitted}`,
+ );
+ shownToastsRef.current.delete(
+ `${transactionId}-${TransactionStatus.confirmed}`,
+ );
+ }, 5000);
+ break;
+ case TransactionStatus.failed:
+ showToast(EarnToastOptions.mUsdConversion.failed);
+ shownToastsRef.current.add(toastKey);
+ // Clean up entries for this transaction after final status
+ setTimeout(() => {
+ shownToastsRef.current.delete(
+ `${transactionId}-${TransactionStatus.submitted}`,
+ );
+ shownToastsRef.current.delete(
+ `${transactionId}-${TransactionStatus.failed}`,
+ );
+ }, 5000);
+ break;
+ default:
+ break;
+ }
+ };
+
+ Engine.controllerMessenger.subscribe(
+ 'TransactionController:transactionStatusUpdated',
+ handleTransactionStatusUpdated,
+ );
+
+ return () => {
+ Engine.controllerMessenger.unsubscribe(
+ 'TransactionController:transactionStatusUpdated',
+ handleTransactionStatusUpdated,
+ );
+ };
+ }, [showToast, EarnToastOptions.mUsdConversion]);
+};
diff --git a/app/components/UI/Earn/routes/index.tsx b/app/components/UI/Earn/routes/index.tsx
index d24fa2aaa5a..fc0d81938c2 100644
--- a/app/components/UI/Earn/routes/index.tsx
+++ b/app/components/UI/Earn/routes/index.tsx
@@ -5,6 +5,7 @@ import EarnLendingDepositConfirmationView from '../../Earn/Views/EarnLendingDepo
import EarnLendingWithdrawalConfirmationView from '../Views/EarnLendingWithdrawalConfirmationView';
import EarnLendingMaxWithdrawalModal from '../modals/LendingMaxWithdrawalModal';
import LendingLearnMoreModal from '../LendingLearnMoreModal';
+import { Confirm } from '../../../Views/confirmations/components/confirm';
const Stack = createStackNavigator();
const ModalStack = createStackNavigator();
@@ -27,6 +28,14 @@ const EarnScreenStack = () => (
name={Routes.EARN.LENDING_WITHDRAWAL_CONFIRMATION}
component={EarnLendingWithdrawalConfirmationView}
/>
+
);
diff --git a/app/components/UI/Earn/selectors/featureFlags/index.test.ts b/app/components/UI/Earn/selectors/featureFlags/index.test.ts
index d3e7f93df85..48e4bd21192 100644
--- a/app/components/UI/Earn/selectors/featureFlags/index.test.ts
+++ b/app/components/UI/Earn/selectors/featureFlags/index.test.ts
@@ -3,7 +3,9 @@ import {
selectPooledStakingServiceInterruptionBannerEnabledFlag,
selectStablecoinLendingEnabledFlag,
selectStablecoinLendingServiceInterruptionBannerEnabledFlag,
+ selectMusdConversionPaymentTokensAllowlist,
} from '.';
+import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd';
import mockedEngine from '../../../../../core/__mocks__/MockedEngine';
import {
mockedState,
@@ -814,4 +816,202 @@ describe('Earn Feature Flag Selectors', () => {
});
});
});
+
+ describe('selectMusdConversionPaymentTokensAllowlist', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('returns parsed remote allowlist when available', () => {
+ const remoteAllowlist = {
+ '0x1': ['USDC', 'USDT'],
+ '0xe708': ['USDC'],
+ };
+
+ const stateWithRemoteAllowlist = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ earnMusdConvertibleTokensAllowlist: remoteAllowlist,
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectMusdConversionPaymentTokensAllowlist(
+ stateWithRemoteAllowlist,
+ );
+
+ expect(result['0x1']).toEqual([
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC on Mainnet
+ '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT on Mainnet
+ ]);
+ });
+
+ it('falls back to local env variable when remote unavailable', () => {
+ const localAllowlist = {
+ '0x1': ['USDC', 'DAI'],
+ };
+ process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST =
+ JSON.stringify(localAllowlist);
+
+ const stateWithoutRemote = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result =
+ selectMusdConversionPaymentTokensAllowlist(stateWithoutRemote);
+
+ expect(result['0x1']).toEqual([
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
+ '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI
+ ]);
+
+ delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST;
+ });
+
+ it('falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN when both unavailable', () => {
+ delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST;
+
+ const stateWithoutRemote = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result =
+ selectMusdConversionPaymentTokensAllowlist(stateWithoutRemote);
+
+ expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN);
+ });
+
+ it('handles JSON parsing errors for local env gracefully', () => {
+ process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST = 'invalid json';
+
+ const stateWithoutRemote = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {},
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result =
+ selectMusdConversionPaymentTokensAllowlist(stateWithoutRemote);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST',
+ ),
+ expect.anything(),
+ );
+ // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN
+ expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN);
+
+ delete process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST;
+ });
+
+ it('handles JSON parsing errors for remote flag gracefully', () => {
+ const stateWithInvalidRemote = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ earnMusdConvertibleTokensAllowlist:
+ 'invalid json string that cannot be parsed',
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectMusdConversionPaymentTokensAllowlist(
+ stateWithInvalidRemote,
+ );
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Failed to parse remote earnMusdConvertibleTokensAllowlist',
+ ),
+ expect.anything(),
+ );
+ // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN
+ expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN);
+ });
+
+ it('falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN when remote flag is not formatted correctly a object keyed by chain IDs with array of token symbols as values', () => {
+ const stateWithArrayRemote = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ earnMusdConvertibleTokensAllowlist: ['0x1', 'USDC'],
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result =
+ selectMusdConversionPaymentTokensAllowlist(stateWithArrayRemote);
+
+ // Falls back to CONVERTIBLE_STABLECOINS_BY_CHAIN since array is invalid
+ expect(result).toEqual(CONVERTIBLE_STABLECOINS_BY_CHAIN);
+ });
+
+ it('converts symbol allowlist to address mapping', () => {
+ const remoteAllowlist = {
+ '0x1': ['USDC', 'USDT', 'DAI'],
+ };
+
+ const stateWithRemoteAllowlist = {
+ engine: {
+ backgroundState: {
+ RemoteFeatureFlagController: {
+ remoteFeatureFlags: {
+ earnMusdConvertibleTokensAllowlist: remoteAllowlist,
+ },
+ cacheTimestamp: 0,
+ },
+ },
+ },
+ };
+
+ const result = selectMusdConversionPaymentTokensAllowlist(
+ stateWithRemoteAllowlist,
+ );
+
+ expect(result['0x1']).toEqual([
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
+ '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
+ '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI
+ ]);
+ });
+ });
});
diff --git a/app/components/UI/Earn/selectors/featureFlags/index.ts b/app/components/UI/Earn/selectors/featureFlags/index.ts
index d4df9d28515..4db50da3093 100644
--- a/app/components/UI/Earn/selectors/featureFlags/index.ts
+++ b/app/components/UI/Earn/selectors/featureFlags/index.ts
@@ -4,6 +4,9 @@ import {
validatedVersionGatedFeatureFlag,
VersionGatedFeatureFlag,
} from '../../../../../util/remoteFeatureFlag';
+import { Hex } from '@metamask/utils';
+import { CONVERTIBLE_STABLECOINS_BY_CHAIN } from '../../constants/musd';
+import { convertSymbolAllowlistToAddresses } from '../../utils/musd';
export const selectPooledStakingEnabledFlag = createSelector(
selectRemoteFeatureFlags,
@@ -51,3 +54,77 @@ export const selectStablecoinLendingServiceInterruptionBannerEnabledFlag =
// Fallback to local flag if remote flag is not available
return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag;
});
+
+export const selectIsMusdConversionFlowEnabledFlag = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags) => {
+ const localFlag = process.env.MM_MUSD_CONVERSION_FLOW_ENABLED === 'true';
+ const remoteFlag =
+ remoteFeatureFlags?.earnMusdConversionFlowEnabled as unknown as VersionGatedFeatureFlag;
+
+ // Fallback to local flag if remote flag is not available
+ return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag;
+ },
+);
+
+/**
+ * Selects the allowed payment tokens for mUSD conversion from remote config or local fallback.
+ * Returns a mapping of chain IDs to arrays of token addresses that users can pay with to convert to mUSD.
+ *
+ * The flag uses JSON format: { "hexChainId": ["tokenSymbol1", "tokenSymbol2"] }
+ *
+ * Example: { "0x1": ["USDC", "USDT"], "0xa4b1": ["USDC", "DAI"] }
+ *
+ * If both remote and local are unavailable, allows all supported payment tokens.
+ */
+export const selectMusdConversionPaymentTokensAllowlist = createSelector(
+ selectRemoteFeatureFlags,
+ (remoteFeatureFlags): Record => {
+ let localAllowlist: Record | null = null;
+ try {
+ const localEnvValue = process.env.MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST;
+
+ if (localEnvValue) {
+ const parsed = JSON.parse(localEnvValue);
+ localAllowlist = convertSymbolAllowlistToAddresses(parsed);
+ }
+ } catch (error) {
+ console.warn(
+ 'Failed to parse MM_MUSD_CONVERTIBLE_TOKENS_ALLOWLIST:',
+ error,
+ );
+ }
+
+ // RemoteFeatureFlagController already parses the flag.
+ const remoteAllowlist =
+ remoteFeatureFlags?.earnMusdConvertibleTokensAllowlist;
+
+ if (remoteAllowlist) {
+ try {
+ const parsedRemote =
+ typeof remoteAllowlist === 'string'
+ ? JSON.parse(remoteAllowlist)
+ : remoteAllowlist;
+
+ // Validate it's an object (not array) before passing to converter
+ if (
+ parsedRemote &&
+ typeof parsedRemote === 'object' &&
+ !Array.isArray(parsedRemote)
+ ) {
+ return convertSymbolAllowlistToAddresses(
+ parsedRemote as Record,
+ );
+ }
+ } catch (error) {
+ console.warn(
+ 'Failed to parse remote earnMusdConvertibleTokensAllowlist. ' +
+ 'Expected JSON string format: {"0x1":["USDC","USDT"]}',
+ error,
+ );
+ }
+ }
+
+ return localAllowlist || CONVERTIBLE_STABLECOINS_BY_CHAIN;
+ },
+);
diff --git a/app/components/UI/Earn/utils/musd.test.ts b/app/components/UI/Earn/utils/musd.test.ts
new file mode 100644
index 00000000000..9e709f5c7c7
--- /dev/null
+++ b/app/components/UI/Earn/utils/musd.test.ts
@@ -0,0 +1,283 @@
+import { Hex } from '@metamask/utils';
+import {
+ convertSymbolAllowlistToAddresses,
+ isMusdConversionPaymentToken,
+} from './musd';
+import { NETWORKS_CHAIN_ID } from '../../../../constants/network';
+
+describe('convertSymbolAllowlistToAddresses', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ consoleWarnSpy.mockRestore();
+ });
+
+ describe('valid conversions', () => {
+ it('converts symbols to addresses for Mainnet', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'USDT', 'DAI'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(3);
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ );
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ );
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0x6b175474e89094c44da98b954eedeac495271d0f',
+ );
+ });
+ });
+
+ describe('invalid chain IDs', () => {
+ it('warns and skips unsupported chain ID', () => {
+ const input = {
+ '0x999': ['USDC'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Unsupported chain ID "0x999"'),
+ );
+ expect(Object.keys(result)).toHaveLength(0);
+ });
+
+ it('processes valid chains and warns about invalid chains', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: ['USDC'],
+ '0x999': ['USDT'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeDefined();
+ expect(result['0x999' as Hex]).toBeUndefined();
+ });
+ });
+
+ describe('invalid token symbols', () => {
+ it('warns about invalid token symbols and excludes them', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'INVALID_TOKEN'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid token symbols'),
+ );
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('INVALID_TOKEN'),
+ );
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(1);
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ );
+ });
+
+ it('returns empty result when all symbols are invalid', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: ['INVALID1', 'INVALID2'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeUndefined();
+ });
+ });
+
+ describe('mixed valid and invalid symbols', () => {
+ it('includes valid symbols and warns about invalid ones', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: ['USDC', 'INVALID', 'USDT'],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid token symbols'),
+ );
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toHaveLength(2);
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ );
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toContain(
+ '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns empty object for empty input', () => {
+ const input = {};
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(result).toEqual({});
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+
+ it('handles empty symbol array', () => {
+ const input = {
+ [NETWORKS_CHAIN_ID.MAINNET]: [],
+ };
+
+ const result = convertSymbolAllowlistToAddresses(input);
+
+ expect(result[NETWORKS_CHAIN_ID.MAINNET]).toBeUndefined();
+ expect(consoleWarnSpy).not.toHaveBeenCalled();
+ });
+ });
+});
+
+describe('isMusdConversionPaymentToken', () => {
+ describe('supported chains with valid tokens', () => {
+ it('returns true for USDC on Mainnet', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true for DAI on Mainnet', () => {
+ const result = isMusdConversionPaymentToken(
+ '0x6b175474e89094c44da98b954eedeac495271d0f',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('case-insensitive address matching', () => {
+ it('returns true for mixed case USDC address on Mainnet', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xA0B86991c6218B36c1d19D4a2e9Eb0cE3606eB48',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(true);
+ });
+ });
+
+ describe('unsupported chains', () => {
+ it('returns false for valid token on unsupported chain', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ '0x999' as Hex,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for Polygon chain', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ '0x89' as Hex,
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('non-convertible tokens', () => {
+ it('returns false for random address on Mainnet', () => {
+ const result = isMusdConversionPaymentToken(
+ '0x1234567890123456789012345678901234567890',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for WETH address on Mainnet', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('custom allowlist', () => {
+ it('uses custom allowlist when provided', () => {
+ const customAllowlist: Record = {
+ [NETWORKS_CHAIN_ID.MAINNET]: [
+ '0x1234567890123456789012345678901234567890' as Hex,
+ ],
+ };
+
+ const result = isMusdConversionPaymentToken(
+ '0x1234567890123456789012345678901234567890',
+ NETWORKS_CHAIN_ID.MAINNET,
+ customAllowlist,
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false for default convertible token when custom allowlist excludes it', () => {
+ const customAllowlist: Record = {
+ [NETWORKS_CHAIN_ID.MAINNET]: [
+ '0x1234567890123456789012345678901234567890' as Hex,
+ ],
+ };
+
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ NETWORKS_CHAIN_ID.MAINNET,
+ customAllowlist,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('works with empty custom allowlist', () => {
+ const customAllowlist: Record = {};
+
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ NETWORKS_CHAIN_ID.MAINNET,
+ customAllowlist,
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns false for empty address', () => {
+ const result = isMusdConversionPaymentToken(
+ '',
+ NETWORKS_CHAIN_ID.MAINNET,
+ );
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for empty chain ID', () => {
+ const result = isMusdConversionPaymentToken(
+ '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
+ '',
+ );
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/app/components/UI/Earn/utils/musd.ts b/app/components/UI/Earn/utils/musd.ts
new file mode 100644
index 00000000000..abd39e0b164
--- /dev/null
+++ b/app/components/UI/Earn/utils/musd.ts
@@ -0,0 +1,87 @@
+/**
+ * mUSD Conversion Utility Functions for Earn namespace
+ */
+
+import { Hex } from '@metamask/utils';
+import {
+ STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN,
+ CONVERTIBLE_STABLECOINS_BY_CHAIN,
+} from '../constants/musd';
+
+/**
+ * Converts a chain-to-symbol allowlist to a chain-to-address mapping.
+ * Used to translate the feature flag format to the format used by isMusdConversionPaymentToken.
+ *
+ * @param allowlistBySymbol - Object mapping chain IDs to arrays of token symbols
+ * @returns Object mapping chain IDs to arrays of token addresses
+ * @example
+ * convertSymbolAllowlistToAddresses({
+ * '0x1': ['USDC', 'USDT', 'DAI'],
+ * '0xa4b1': ['USDC', 'USDT'],
+ * });
+ */
+export const convertSymbolAllowlistToAddresses = (
+ allowlistBySymbol: Record,
+): Record => {
+ const result: Record = {};
+
+ for (const [chainId, symbols] of Object.entries(allowlistBySymbol)) {
+ const chainMapping = STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN[chainId as Hex];
+ if (!chainMapping) {
+ console.warn(
+ `[mUSD Allowlist] Unsupported chain ID "${chainId}" in allowlist. ` +
+ `Supported chains: ${Object.keys(STABLECOIN_SYMBOL_TO_ADDRESS_BY_CHAIN).join(', ')}`,
+ );
+ continue;
+ }
+
+ const addresses: Hex[] = [];
+ const invalidSymbols: string[] = [];
+
+ for (const symbol of symbols) {
+ const address = chainMapping[symbol];
+ if (address) {
+ addresses.push(address);
+ continue;
+ }
+ invalidSymbols.push(symbol);
+ }
+
+ if (invalidSymbols.length > 0) {
+ console.warn(
+ `[mUSD Allowlist] Invalid token symbols for chain ${chainId}: ${invalidSymbols.join(', ')}. ` +
+ `Supported tokens: ${Object.keys(chainMapping).join(', ')}`,
+ );
+ }
+
+ if (addresses.length > 0) {
+ result[chainId as Hex] = addresses;
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Checks if a token is an allowed payment token for mUSD conversion based on its address and chain ID.
+ * Centralizes the logic for determining which tokens on which chains can show the "Convert" CTA.
+ *
+ * @param tokenAddress - The token contract address (case-insensitive)
+ * @param chainId - The chain ID where the token exists
+ * @param allowlist - Optional allowlist to use instead of default CONVERTIBLE_STABLECOINS_BY_CHAIN
+ * @returns true if the token is an allowed payment token for mUSD conversion, false otherwise
+ */
+export const isMusdConversionPaymentToken = (
+ tokenAddress: string,
+ chainId: string,
+ allowlist: Record = CONVERTIBLE_STABLECOINS_BY_CHAIN,
+): boolean => {
+ const convertibleTokens = allowlist[chainId as Hex];
+ if (!convertibleTokens) {
+ return false;
+ }
+
+ return convertibleTokens
+ .map((addr) => addr.toLowerCase())
+ .includes(tokenAddress.toLowerCase());
+};
diff --git a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
index 22040519791..d21f75877a4 100644
--- a/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
+++ b/app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx
@@ -16,10 +16,17 @@ import useStakingEligibility from '../../hooks/useStakingEligibility';
import { RootState } from '../../../../../reducers';
import { SolScope } from '@metamask/keyring-api';
import Engine from '../../../../../core/Engine';
-import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags';
+import {
+ selectIsMusdConversionFlowEnabledFlag,
+ selectMusdConversionPaymentTokensAllowlist,
+ selectStablecoinLendingEnabledFlag,
+} from '../../../Earn/selectors/featureFlags';
import { useFeatureFlag } from '../../../../../components/hooks/useFeatureFlag';
import { TokenI } from '../../../Tokens/types';
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
+import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion';
+import { Alert } from 'react-native';
+import { Hex } from '@metamask/utils';
const mockNavigate = jest.fn();
@@ -84,6 +91,23 @@ jest.mock('../../../../../util/environment', () => ({
// Mock the feature flags selector
jest.mock('../../../Earn/selectors/featureFlags', () => ({
selectStablecoinLendingEnabledFlag: jest.fn().mockReturnValue(true),
+ selectIsMusdConversionFlowEnabledFlag: jest.fn().mockReturnValue(false),
+ selectMusdConversionPaymentTokensAllowlist: jest.fn().mockReturnValue({}),
+}));
+
+jest.mock('../../../Earn/hooks/useMusdConversion', () => ({
+ useMusdConversion: jest.fn(() => ({
+ initiateConversion: jest.fn(),
+ error: null,
+ })),
+}));
+
+jest.mock('../../../../../selectors/earnController/earn', () => ({
+ earnSelectors: {
+ selectPrimaryEarnExperienceTypeForAsset: jest.fn((_state, asset) =>
+ asset.symbol === 'USDC' ? 'STABLECOIN_LENDING' : 'POOLED_STAKING',
+ ),
+ },
}));
jest.mock('../../../../../components/hooks/useFeatureFlag', () => {
@@ -96,21 +120,6 @@ jest.mock('../../../../../components/hooks/useFeatureFlag', () => {
};
});
-jest.mock('../../../../../selectors/earnController/earn', () => {
- const { EARN_EXPERIENCES } = jest.requireActual(
- '../../../Earn/constants/experiences',
- );
- return {
- earnSelectors: {
- selectPrimaryEarnExperienceTypeForAsset: jest.fn((_state, asset) =>
- asset.symbol === 'USDC'
- ? EARN_EXPERIENCES.STABLECOIN_LENDING
- : EARN_EXPERIENCES.POOLED_STAKING,
- ),
- },
- };
-});
-
(useMetrics as jest.MockedFn).mockReturnValue({
trackEvent: jest.fn(),
createEventBuilder: MetricsEventBuilder.createEventBuilder,
@@ -366,4 +375,174 @@ describe('StakeButton', () => {
expect(queryByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeNull();
});
+
+ describe('mUSD Conversion', () => {
+ const mockInitiateConversion = jest.fn();
+
+ const useMusdConversionMock = jest.mocked(useMusdConversion);
+ const selectIsMusdConversionFlowEnabledFlagMock = jest.mocked(
+ selectIsMusdConversionFlowEnabledFlag,
+ );
+ const selectMusdConversionPaymentTokensAllowlistMock = jest.mocked(
+ selectMusdConversionPaymentTokensAllowlist,
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockInitiateConversion.mockResolvedValue('tx-123');
+ useMusdConversionMock.mockReturnValue({
+ initiateConversion: mockInitiateConversion,
+ error: null,
+ });
+ });
+
+ it('renders Convert CTA for convertible stablecoin when flag enabled', () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ });
+
+ const { getByText } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ expect(getByText('Convert')).toBeDefined();
+ });
+
+ it('calls initiateConversion when Convert button pressed', async () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON));
+
+ await waitFor(() => {
+ expect(mockInitiateConversion).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls initiateConversion with correct parameters', async () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ const mockAllowlist = {
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ } as Record;
+
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue(
+ mockAllowlist,
+ );
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON));
+
+ await waitFor(() => {
+ expect(mockInitiateConversion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ outputToken: expect.objectContaining({
+ symbol: 'MUSD',
+ decimals: 6,
+ }),
+ preferredPaymentToken: expect.objectContaining({
+ address: expect.any(String),
+ chainId: expect.any(String),
+ }),
+ allowedPaymentTokens: mockAllowlist,
+ navigationStack: Routes.EARN.ROOT,
+ }),
+ );
+ });
+ });
+
+ it('shows Alert when conversion fails', async () => {
+ const mockAlert = jest.spyOn(Alert, 'alert');
+ const conversionError = new Error('Conversion failed');
+ mockInitiateConversion.mockRejectedValue(conversionError);
+
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ fireEvent.press(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON));
+
+ await waitFor(() => {
+ expect(mockAlert).toHaveBeenCalledWith(
+ 'Conversion Failed',
+ expect.stringContaining('Conversion failed'),
+ expect.any(Array),
+ );
+ });
+
+ mockAlert.mockRestore();
+ });
+
+ it('renders button for convertible stablecoin even with zero balance', () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ });
+
+ const zeroBalanceAsset = {
+ ...MOCK_USDC_MAINNET_ASSET,
+ balance: '0',
+ };
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ expect(getByTestId(WalletViewSelectorsIDs.STAKE_BUTTON)).toBeDefined();
+ });
+
+ it('does not render Convert CTA when flag disabled', () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(false);
+
+ const { queryByText } = renderWithProvider(
+ ,
+ {
+ state: STATE_MOCK,
+ },
+ );
+
+ expect(queryByText('Convert')).toBeNull();
+ });
+
+ it('does not render Convert CTA for non-convertible tokens', () => {
+ selectIsMusdConversionFlowEnabledFlagMock.mockReturnValue(true);
+ // Allowlist doesn't include ETH address, so ETH won't show Convert CTA
+ selectMusdConversionPaymentTokensAllowlistMock.mockReturnValue({
+ '0x1': [MOCK_USDC_MAINNET_ASSET.address as Hex],
+ });
+
+ const { queryByText } = renderComponent();
+
+ expect(queryByText('Convert')).toBeNull();
+ });
+ });
});
diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx
index 543493ccd77..88316a90f1e 100644
--- a/app/components/UI/Stake/components/StakeButton/index.tsx
+++ b/app/components/UI/Stake/components/StakeButton/index.tsx
@@ -1,7 +1,7 @@
import { toHex } from '@metamask/controller-utils';
import { useNavigation } from '@react-navigation/native';
-import React from 'react';
-import { Pressable } from 'react-native';
+import React, { useMemo, useCallback } from 'react';
+import { Alert, TouchableOpacity } from 'react-native';
import { useSelector } from 'react-redux';
import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors';
import { strings } from '../../../../../../locales/i18n';
@@ -22,7 +22,11 @@ import { useTheme } from '../../../../../util/theme';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences';
import useEarnTokens from '../../../Earn/hooks/useEarnTokens';
-import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags';
+import {
+ selectStablecoinLendingEnabledFlag,
+ selectIsMusdConversionFlowEnabledFlag,
+ selectMusdConversionPaymentTokensAllowlist,
+} from '../../../Earn/selectors/featureFlags';
import {
useFeatureFlag,
FeatureFlagNames,
@@ -39,10 +43,19 @@ import { earnSelectors } from '../../../../../selectors/earnController/earn';
///: BEGIN:ONLY_INCLUDE_IF(tron)
import { selectTrxStakingEnabled } from '../../../../../selectors/featureFlagController/trxStakingEnabled';
///: END:ONLY_INCLUDE_IF
+import {
+ ETHEREUM_MAINNET_CHAIN_ID,
+ MUSD_TOKEN_MAINNET,
+} from '../../../Earn/constants/musd';
+import { isMusdConversionPaymentToken } from '../../../Earn/utils/musd';
+import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion';
+import Logger from '../../../../../util/Logger';
+
interface StakeButtonProps {
asset: TokenI;
}
+// TODO: Rename to EarnCta to better describe this component's purpose.
const StakeButtonContent = ({ asset }: StakeButtonProps) => {
const { colors } = useTheme();
const styles = createStyles(colors);
@@ -60,6 +73,12 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
const isStablecoinLendingEnabled = useSelector(
selectStablecoinLendingEnabledFlag,
);
+ const isMusdConversionFlowEnabled = useSelector(
+ selectIsMusdConversionFlowEnabledFlag,
+ );
+ const musdConversionPaymentTokensAllowlist = useSelector(
+ selectMusdConversionPaymentTokensAllowlist,
+ );
///: BEGIN:ONLY_INCLUDE_IF(tron)
const isTrxStakingEnabled = useSelector(selectTrxStakingEnabled);
@@ -77,9 +96,29 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
earnSelectors.selectPrimaryEarnExperienceTypeForAsset(state, asset),
);
+ const { initiateConversion } = useMusdConversion();
+
const areEarnExperiencesDisabled =
!isPooledStakingEnabled && !isStablecoinLendingEnabled;
+ const isConvertibleStablecoin = useMemo(
+ () =>
+ isMusdConversionFlowEnabled &&
+ asset?.chainId &&
+ asset?.address &&
+ isMusdConversionPaymentToken(
+ asset.address,
+ asset.chainId,
+ musdConversionPaymentTokensAllowlist,
+ ),
+ [
+ isMusdConversionFlowEnabled,
+ asset?.chainId,
+ asset?.address,
+ musdConversionPaymentTokensAllowlist,
+ ],
+ );
+
const handleStakeRedirect = async () => {
///: BEGIN:ONLY_INCLUDE_IF(tron)
if (isTronNative && isTrxStakingEnabled) {
@@ -197,7 +236,54 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
});
};
+ const handleConvertToMUSD = useCallback(async () => {
+ try {
+ if (!asset?.address || !asset?.chainId) {
+ throw new Error('Asset address or chain ID is not set');
+ }
+
+ await initiateConversion({
+ outputToken: {
+ address: MUSD_TOKEN_MAINNET.address,
+ // We want to convert to mUSD on Ethereum Mainnet only for now.
+ chainId: ETHEREUM_MAINNET_CHAIN_ID,
+ symbol: MUSD_TOKEN_MAINNET.symbol,
+ name: MUSD_TOKEN_MAINNET.name,
+ decimals: MUSD_TOKEN_MAINNET.decimals,
+ },
+ preferredPaymentToken: {
+ address: toHex(asset.address),
+ chainId: toHex(asset.chainId),
+ },
+ allowedPaymentTokens: musdConversionPaymentTokensAllowlist,
+ navigationStack: Routes.EARN.ROOT,
+ });
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ '[mUSD Conversion] Failed to initiate conversion',
+ );
+
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error occurred';
+ Alert.alert(
+ 'Conversion Failed',
+ `Unable to start mUSD conversion: ${errorMessage}`,
+ [{ text: 'OK' }],
+ );
+ }
+ }, [
+ asset?.address,
+ asset?.chainId,
+ initiateConversion,
+ musdConversionPaymentTokensAllowlist,
+ ]);
+
const onEarnButtonPress = async () => {
+ if (isConvertibleStablecoin) {
+ return handleConvertToMUSD();
+ }
+
if (primaryExperienceType === EARN_EXPERIENCES.POOLED_STAKING) {
return handleStakeRedirect();
}
@@ -209,13 +295,15 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
if (
areEarnExperiencesDisabled ||
- (!earnToken?.isETH && earnToken?.balanceMinimalUnit === '0') ||
+ (!isConvertibleStablecoin && // Show for convertible stablecoins even with 0 balance
+ !earnToken?.isETH &&
+ earnToken?.balanceMinimalUnit === '0') ||
(earnToken?.isETH && !isPooledStakingEnabled)
)
return <>>;
return (
- {
{(() => {
+ if (isConvertibleStablecoin) {
+ return strings('asset_overview.convert');
+ }
+
const aprNumber = Number(earnToken?.experience?.apr);
const aprText =
Number.isFinite(aprNumber) && aprNumber > 0
@@ -233,7 +325,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
return `${strings('stake.earn')}${aprText}`;
})()}
-
+
);
};
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx
index 304cb04fad4..c7918c75227 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx
@@ -44,6 +44,19 @@ jest.mock('../../../Earn/hooks/useEarnTokens', () => ({
default: () => ({ getEarnToken: jest.fn() }),
}));
+jest.mock('../../../Earn/hooks/useMusdConversion', () => ({
+ useMusdConversion: () => ({
+ initiateConversion: jest.fn(),
+ error: null,
+ }),
+}));
+
+jest.mock('../../../../../selectors/earnController/earn', () => ({
+ earnSelectors: {
+ selectPrimaryEarnExperienceTypeForAsset: jest.fn(() => 'pooled-staking'),
+ },
+}));
+
jest.mock('../../../Stake/hooks/useStakingChain', () => ({
__esModule: true,
default: () => ({ isStakingSupportedChain: false }),
@@ -51,8 +64,10 @@ jest.mock('../../../Stake/hooks/useStakingChain', () => ({
}));
jest.mock('../../../Earn/selectors/featureFlags', () => ({
- selectPooledStakingEnabledFlag: () => false,
+ selectPooledStakingEnabledFlag: () => true, // Enable to show Earn button
selectStablecoinLendingEnabledFlag: () => false,
+ selectIsMusdConversionFlowEnabledFlag: () => false,
+ selectMusdConversionPaymentTokensAllowlist: () => ({}),
}));
jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({
@@ -182,14 +197,44 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
// Default mock setup
mockUseSelector.mockImplementation(
(selector: (state: unknown) => unknown) => {
- if (selector.toString().includes('selectAsset')) {
+ if (!selector || typeof selector !== 'function') {
+ return {};
+ }
+
+ const selectorString = selector.toString();
+
+ // TokenListItemBip44 selectors
+ if (selectorString.includes('selectAsset')) {
return asset;
}
- if (selector.toString().includes('selectShowFiatInTestnets')) {
+ if (selectorString.includes('selectShowFiatInTestnets')) {
return false;
}
+ // StakeButton selectors
+ if (selectorString.includes('selectIsStakeableToken')) {
+ return true; // Enable to show Earn button
+ }
+
+ if (selectorString.includes('state.browser.tabs')) {
+ return [];
+ }
+
+ if (selectorString.includes('selectEvmChainId')) {
+ return '0x1';
+ }
+
+ if (selectorString.includes('selectNetworkConfigurationByChainId')) {
+ return { name: 'Ethereum Mainnet' };
+ }
+
+ if (
+ selectorString.includes('selectPrimaryEarnExperienceTypeForAsset')
+ ) {
+ return 'pooled-staking';
+ }
+
return {};
},
);
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx
index 37562e4fe69..faff8e6ae39 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.tsx
@@ -25,7 +25,11 @@ import { TokenI } from '../../types';
import { ScamWarningIcon } from '../ScamWarningIcon';
import { FlashListAssetKey } from '..';
import useEarnTokens from '../../../Earn/hooks/useEarnTokens';
-import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags';
+import {
+ selectMusdConversionPaymentTokensAllowlist,
+ selectIsMusdConversionFlowEnabledFlag,
+ selectStablecoinLendingEnabledFlag,
+} from '../../../Earn/selectors/featureFlags';
import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange';
import { selectAsset } from '../../../../../selectors/assets/assets-list';
import Tag from '../../../../../component-library/components/Tags/Tag';
@@ -37,6 +41,7 @@ import AssetLogo from '../../../Assets/components/AssetLogo/AssetLogo';
import { ACCOUNT_TYPE_LABELS } from '../../../../../constants/account-type-labels';
import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens';
+import { isMusdConversionPaymentToken } from '../../../Earn/utils/musd';
export const ACCOUNT_TYPE_LABEL_TEST_ID = 'account-type-label';
@@ -78,6 +83,31 @@ export const TokenListItemBip44 = React.memo(
selectStablecoinLendingEnabledFlag,
);
+ const isMusdConversionFlowEnabled = useSelector(
+ selectIsMusdConversionFlowEnabledFlag,
+ );
+ const musdConversionPaymentTokensAllowlist = useSelector(
+ selectMusdConversionPaymentTokensAllowlist,
+ );
+
+ const isConvertibleStablecoin = useMemo(
+ () =>
+ isMusdConversionFlowEnabled &&
+ asset?.chainId &&
+ asset?.address &&
+ isMusdConversionPaymentToken(
+ asset.address,
+ asset.chainId,
+ musdConversionPaymentTokensAllowlist,
+ ),
+ [
+ isMusdConversionFlowEnabled,
+ asset?.chainId,
+ asset?.address,
+ musdConversionPaymentTokensAllowlist,
+ ],
+ );
+
const pricePercentChange1d = useTokenPricePercentageChange(asset);
// Secondary balance shows percentage change (if available and not on testnet)
@@ -144,11 +174,23 @@ export const TokenListItemBip44 = React.memo(
const shouldShowStablecoinLendingCta =
earnToken && isStablecoinLendingEnabled;
- if (shouldShowStakeCta || shouldShowStablecoinLendingCta) {
+ const shouldShowMusdConvertCta = isConvertibleStablecoin;
+
+ if (
+ shouldShowStakeCta ||
+ shouldShowStablecoinLendingCta ||
+ shouldShowMusdConvertCta
+ ) {
// TODO: Rename to EarnCta
return ;
}
- }, [asset, earnToken, isStablecoinLendingEnabled, isStakeable]);
+ }, [
+ asset,
+ earnToken,
+ isConvertibleStablecoin,
+ isStablecoinLendingEnabled,
+ isStakeable,
+ ]);
if (!asset || !chainId) {
return null;
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx
index 79b88fcf359..d81db9b2a62 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx
@@ -39,13 +39,28 @@ jest.mock('../../../Earn/hooks/useEarnTokens', () => ({
default: () => ({ getEarnToken: jest.fn() }),
}));
+jest.mock('../../../Earn/hooks/useMusdConversion', () => ({
+ useMusdConversion: () => ({
+ initiateConversion: jest.fn(),
+ error: null,
+ }),
+}));
+
+jest.mock('../../../../../selectors/earnController/earn', () => ({
+ earnSelectors: {
+ selectPrimaryEarnExperienceTypeForAsset: jest.fn(() => 'pooled-staking'),
+ },
+}));
+
jest.mock('../../../Stake/hooks/useStakingChain', () => ({
useStakingChainByChainId: () => ({ isStakingSupportedChain: false }),
}));
jest.mock('../../../Earn/selectors/featureFlags', () => ({
- selectPooledStakingEnabledFlag: () => false,
+ selectPooledStakingEnabledFlag: () => true, // Enable to show Earn button
selectStablecoinLendingEnabledFlag: () => false,
+ selectIsMusdConversionFlowEnabledFlag: () => false,
+ selectMusdConversionPaymentTokensAllowlist: () => ({}),
}));
jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({
@@ -1631,23 +1646,24 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
// Default mock setup
mockUseSelector.mockImplementation(
(selector: (state: unknown) => unknown) => {
+ if (!selector || typeof selector !== 'function') {
+ return {};
+ }
+
+ const selectorString = selector.toString();
+
// Return sensible defaults for all selectors
- if (selector.toString().includes('selectIsEvmNetworkSelected'))
- return true;
- if (
- selector.toString().includes('selectSelectedInternalAccountAddress')
- )
+ if (selectorString.includes('selectIsEvmNetworkSelected')) return true;
+ if (selectorString.includes('selectSelectedInternalAccountAddress'))
return '0x123';
- if (selector.toString().includes('selectCurrentCurrency')) return 'USD';
- if (selector.toString().includes('selectShowFiatInTestnets'))
- return false;
- if (selector.toString().includes('selectSingleTokenBalance'))
+ if (selectorString.includes('selectCurrentCurrency')) return 'USD';
+ if (selectorString.includes('selectShowFiatInTestnets')) return false;
+ if (selectorString.includes('selectSingleTokenBalance'))
return { '0x456': '1.23' };
- if (selector.toString().includes('selectSingleTokenPriceMarketData'))
+ if (selectorString.includes('selectSingleTokenPriceMarketData'))
return { price: 100 };
- if (selector.toString().includes('selectCurrencyRateForChainId'))
- return 1.0;
- if (selector.toString().includes('makeSelectAssetByAddressAndChainId'))
+ if (selectorString.includes('selectCurrencyRateForChainId')) return 1.0;
+ if (selectorString.includes('makeSelectAssetByAddressAndChainId'))
return {
address: '0x456',
chainId: '0x1',
@@ -1658,6 +1674,30 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => {
isNative: false,
isETH: false,
};
+
+ // StakeButton selectors - return appropriate mock data
+ if (selectorString.includes('selectIsStakeableToken')) {
+ return true; // Enable to show Earn button
+ }
+
+ if (selectorString.includes('state.browser.tabs')) {
+ return [];
+ }
+
+ if (selectorString.includes('selectEvmChainId')) {
+ return '0x1';
+ }
+
+ if (selectorString.includes('selectNetworkConfigurationByChainId')) {
+ return { name: 'Ethereum Mainnet' };
+ }
+
+ if (
+ selectorString.includes('selectPrimaryEarnExperienceTypeForAsset')
+ ) {
+ return 'pooled-staking';
+ }
+
return {};
},
);
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
index ef2bccb98c8..06dd50998a5 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
@@ -72,11 +72,16 @@ import { makeSelectNonEvmAssetById } from '../../../../../selectors/multichain/m
import { FlashListAssetKey } from '..';
import { makeSelectAssetByAddressAndChainId } from '../../../../../selectors/multichain';
import useEarnTokens from '../../../Earn/hooks/useEarnTokens';
-import { selectStablecoinLendingEnabledFlag } from '../../../Earn/selectors/featureFlags';
+import {
+ selectIsMusdConversionFlowEnabledFlag,
+ selectMusdConversionPaymentTokensAllowlist,
+ selectStablecoinLendingEnabledFlag,
+} from '../../../Earn/selectors/featureFlags';
import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange';
import { MULTICHAIN_NETWORK_DECIMAL_PLACES } from '@metamask/multichain-network-controller';
import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens';
+import { isMusdConversionPaymentToken } from '../../../Earn/utils/musd';
interface TokenListItemProps {
assetKey: FlashListAssetKey;
@@ -273,6 +278,31 @@ export const TokenListItem = React.memo(
const earnToken = getEarnToken(asset as TokenI);
+ const isMusdConversionFlowEnabled = useSelector(
+ selectIsMusdConversionFlowEnabledFlag,
+ );
+ const musdConversionPaymentTokensAllowlist = useSelector(
+ selectMusdConversionPaymentTokensAllowlist,
+ );
+
+ const isConvertibleStablecoin = useMemo(
+ () =>
+ isMusdConversionFlowEnabled &&
+ asset?.chainId &&
+ asset?.address &&
+ isMusdConversionPaymentToken(
+ asset.address,
+ asset.chainId,
+ musdConversionPaymentTokensAllowlist,
+ ),
+ [
+ isMusdConversionFlowEnabled,
+ asset?.chainId,
+ asset?.address,
+ musdConversionPaymentTokensAllowlist,
+ ],
+ );
+
const networkBadgeSource = useCallback(
(currentChainId: Hex) => {
if (isTestNet(currentChainId))
@@ -385,12 +415,23 @@ export const TokenListItem = React.memo(
const shouldShowStablecoinLendingCta =
earnToken && isStablecoinLendingEnabled;
+ const shouldShowMusdConvertCta = isConvertibleStablecoin;
- if (shouldShowStakeCta || shouldShowStablecoinLendingCta) {
+ if (
+ shouldShowStakeCta ||
+ shouldShowStablecoinLendingCta ||
+ shouldShowMusdConvertCta
+ ) {
// TODO: Rename to EarnCta
return ;
}
- }, [asset, earnToken, isStablecoinLendingEnabled, isStakeable]);
+ }, [
+ asset,
+ earnToken,
+ isConvertibleStablecoin,
+ isStablecoinLendingEnabled,
+ isStakeable,
+ ]);
if (!asset || !chainId) {
return null;
diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx
index 12305b4e60b..c65d5e63322 100644
--- a/app/components/Views/confirmations/components/footer/footer.tsx
+++ b/app/components/Views/confirmations/components/footer/footer.tsx
@@ -38,12 +38,14 @@ import {
import { hasTransactionType } from '../../utils/transaction';
import { PredictClaimFooter } from '../predict-confirmations/predict-claim-footer/predict-claim-footer';
import { useIsTransactionPayLoading } from '../../hooks/pay/useTransactionPayData';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../../../UI/Earn/constants/musd';
import { Skeleton } from '../../../../../component-library/components/Skeleton';
const HIDE_FOOTER_BY_DEFAULT_TYPES = [
TransactionType.perpsDeposit,
TransactionType.predictDeposit,
TransactionType.predictWithdraw,
+ MUSD_CONVERSION_TRANSACTION_TYPE,
];
export const Footer = () => {
diff --git a/app/components/Views/confirmations/components/info-root/info-root.test.tsx b/app/components/Views/confirmations/components/info-root/info-root.test.tsx
index 95d71abb948..9f41387b2aa 100644
--- a/app/components/Views/confirmations/components/info-root/info-root.test.tsx
+++ b/app/components/Views/confirmations/components/info-root/info-root.test.tsx
@@ -25,6 +25,27 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({
goBack: jest.fn(),
}),
+ useRoute: jest.fn(() => ({
+ key: 'test-route',
+ name: 'TestRoute',
+ params: {},
+ })),
+}));
+
+jest.mock('../../hooks/ui/useNavbar', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('../../hooks/tokens/useAddToken', () => ({
+ useAddToken: jest.fn(),
+}));
+
+jest.mock('../info/custom-amount-info', () => ({
+ CustomAmountInfo: () => {
+ const { Text } = jest.requireActual('react-native');
+ return Custom Amount Info;
+ },
}));
jest.mock('../../hooks/gas/useGasFeeToken');
diff --git a/app/components/Views/confirmations/components/info-root/info-root.tsx b/app/components/Views/confirmations/components/info-root/info-root.tsx
index ead022478c7..4d0d5bbee62 100644
--- a/app/components/Views/confirmations/components/info-root/info-root.tsx
+++ b/app/components/Views/confirmations/components/info-root/info-root.tsx
@@ -24,6 +24,8 @@ import { PredictDepositInfo } from '../info/predict-deposit-info';
import { hasTransactionType } from '../../utils/transaction';
import { PredictClaimInfo } from '../info/predict-claim-info';
import { PredictWithdrawInfo } from '../info/predict-withdraw-info';
+import { MusdConversionInfo } from '../info/musd-conversion-info';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../../../UI/Earn/constants/musd';
interface ConfirmationInfoComponentRequest {
signatureRequestVersion?: string;
@@ -91,6 +93,13 @@ const Info = ({ route }: InfoProps) => {
return ;
}
+ if (
+ transactionMetadata &&
+ hasTransactionType(transactionMetadata, [MUSD_CONVERSION_TRANSACTION_TYPE])
+ ) {
+ return ;
+ }
+
if (
transactionMetadata &&
hasTransactionType(transactionMetadata, [TransactionType.predictDeposit])
diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
index 50e4d8ec843..7b23424cc36 100644
--- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
+++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx
@@ -48,6 +48,7 @@ import Button, {
} from '../../../../../../component-library/components/Buttons/Button';
import { useAlerts } from '../../../context/alert-system-context';
import { useTransactionConfirm } from '../../../hooks/transactions/useTransactionConfirm';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../../../../UI/Earn/constants/musd';
export interface CustomAmountInfoProps {
children?: ReactNode;
@@ -265,5 +266,9 @@ function useButtonLabel() {
return strings('confirm.deposit_edit_amount_predict_withdraw');
}
+ if (hasTransactionType(transaction, [MUSD_CONVERSION_TRANSACTION_TYPE])) {
+ return strings('earn.musd_conversion.confirmation_button');
+ }
+
return strings('confirm.deposit_edit_amount_done');
}
diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/index.ts b/app/components/Views/confirmations/components/info/musd-conversion-info/index.ts
new file mode 100644
index 00000000000..5319f8fcae3
--- /dev/null
+++ b/app/components/Views/confirmations/components/info/musd-conversion-info/index.ts
@@ -0,0 +1 @@
+export { MusdConversionInfo } from './musd-conversion-info';
diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx
new file mode 100644
index 00000000000..4cc62a33d0b
--- /dev/null
+++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.test.tsx
@@ -0,0 +1,292 @@
+import React from 'react';
+import { Hex } from '@metamask/utils';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import { MusdConversionInfo } from './musd-conversion-info';
+import useNavbar from '../../../hooks/ui/useNavbar';
+import { useAddToken } from '../../../hooks/tokens/useAddToken';
+import { MUSD_TOKEN_MAINNET } from '../../../../../UI/Earn/constants/musd';
+import { useNavigation, useRoute } from '@react-navigation/native';
+import { strings } from '../../../../../../../locales/i18n';
+import { CustomAmountInfo } from '../custom-amount-info';
+
+jest.mock('../../../hooks/ui/useNavbar');
+jest.mock('../../../hooks/tokens/useAddToken');
+
+jest.mock('../custom-amount-info', () => ({
+ CustomAmountInfo: jest.fn(() => null),
+}));
+
+const mockRoute = {
+ key: 'test-route',
+ name: 'MusdConversionInfo',
+ params: {},
+};
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useRoute: jest.fn(() => mockRoute),
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ setOptions: jest.fn(),
+ })),
+ };
+});
+
+describe('MusdConversionInfo', () => {
+ const mockUseNavbar = jest.mocked(useNavbar);
+ const mockUseAddToken = jest.mocked(useAddToken);
+ const mockUseRoute = jest.mocked(useRoute);
+ const mockUseNavigation = jest.mocked(useNavigation);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders without errors when all route params provided', () => {
+ const allowedPaymentTokens: Record = {
+ '0x1': ['0xabc' as Hex],
+ };
+
+ mockRoute.params = {
+ allowedPaymentTokens,
+ preferredPaymentToken: {
+ address: '0xdef' as Hex,
+ chainId: '0x1' as Hex,
+ },
+ outputToken: {
+ address: '0x123' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'TEST',
+ name: 'Test Token',
+ decimals: 6,
+ },
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(mockUseNavbar).toHaveBeenCalled();
+ expect(mockUseAddToken).toHaveBeenCalled();
+ });
+ });
+
+ describe('navbar title', () => {
+ it('calls useNavbar with earn_rewards_with title for mUSD token', () => {
+ mockRoute.params = {
+ outputToken: {
+ symbol: 'MUSD',
+ address: MUSD_TOKEN_MAINNET.address,
+ chainId: '0x1' as Hex,
+ name: 'MUSD',
+ decimals: 6,
+ },
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(mockUseNavbar).toHaveBeenCalledWith(
+ expect.stringContaining(
+ strings('earn.musd_conversion.earn_rewards_with'),
+ ),
+ );
+ });
+ });
+
+ describe('useAddToken', () => {
+ it('calls useAddToken with outputToken info', () => {
+ const outputToken = {
+ address: '0x123' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'TEST',
+ name: 'Test Token',
+ decimals: 6,
+ };
+
+ mockRoute.params = {
+ outputToken,
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(mockUseAddToken).toHaveBeenCalledWith({
+ chainId: outputToken.chainId,
+ decimals: outputToken.decimals,
+ name: outputToken.name,
+ symbol: outputToken.symbol,
+ tokenAddress: outputToken.address,
+ });
+ });
+ });
+
+ describe.skip('allowedPaymentTokens validation', () => {
+ const mockOutputToken = {
+ address: '0x123' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'TEST',
+ name: 'Test Token',
+ decimals: 6,
+ };
+
+ it('passes valid allowedPaymentTokens to CustomAmountInfo', () => {
+ const allowedPaymentTokens: Record = {
+ '0x1': ['0xabc' as Hex],
+ };
+
+ mockRoute.params = {
+ allowedPaymentTokens,
+ outputToken: mockOutputToken,
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(CustomAmountInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedPaymentTokens,
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('warns and passes undefined when allowedPaymentTokens is invalid', () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+
+ const invalidAllowedTokens = {
+ notHex: ['0xabc'],
+ };
+
+ mockRoute.params = {
+ allowedPaymentTokens: invalidAllowedTokens,
+ outputToken: mockOutputToken,
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Invalid allowedPaymentTokens structure'),
+ invalidAllowedTokens,
+ );
+ expect(CustomAmountInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedPaymentTokens: undefined,
+ }),
+ expect.anything(),
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+
+ it('passes undefined to CustomAmountInfo when allowedPaymentTokens not provided', () => {
+ mockRoute.params = {
+ outputToken: mockOutputToken,
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(CustomAmountInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allowedPaymentTokens: undefined,
+ }),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe.skip('preferredPaymentToken', () => {
+ it('passes preferredPaymentToken to CustomAmountInfo when provided', () => {
+ const preferredPaymentToken = {
+ address: '0xdef' as Hex,
+ chainId: '0x1' as Hex,
+ };
+
+ mockRoute.params = {
+ preferredPaymentToken,
+ outputToken: {
+ address: '0x123' as Hex,
+ chainId: '0x1' as Hex,
+ symbol: 'TEST',
+ name: 'Test Token',
+ decimals: 6,
+ },
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ renderWithProvider(, {
+ state: {},
+ });
+
+ expect(CustomAmountInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ preferredPaymentToken,
+ }),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('error handling', () => {
+ it('navigates back and logs error when outputToken missing', () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
+ const mockGoBack = jest.fn();
+
+ mockUseNavigation.mockReturnValue({
+ navigate: jest.fn(),
+ goBack: mockGoBack,
+ setOptions: jest.fn(),
+ } as unknown as ReturnType);
+
+ mockRoute.params = {
+ preferredPaymentToken: {
+ address: '0xdef' as Hex,
+ chainId: '0x1' as Hex,
+ },
+ // outputToken is missing
+ };
+
+ mockUseRoute.mockReturnValue(mockRoute);
+
+ const { toJSON } = renderWithProvider(, {
+ state: {},
+ });
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('outputToken is required but was not provided'),
+ );
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ expect(toJSON()).toBeNull();
+
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});
diff --git a/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx
new file mode 100644
index 00000000000..7b920c832ec
--- /dev/null
+++ b/app/components/Views/confirmations/components/info/musd-conversion-info/musd-conversion-info.tsx
@@ -0,0 +1,72 @@
+import React, { useEffect } from 'react';
+import { strings } from '../../../../../../../locales/i18n';
+import useNavbar from '../../../hooks/ui/useNavbar';
+import { CustomAmountInfo } from '../custom-amount-info';
+import { MUSD_TOKEN_MAINNET } from '../../../../../UI/Earn/constants/musd';
+import { useAddToken } from '../../../hooks/tokens/useAddToken';
+import { Hex } from '@metamask/utils';
+import { useRoute, RouteProp, useNavigation } from '@react-navigation/native';
+import { MusdConversionConfig } from '../../../../../UI/Earn/hooks/useMusdConversion';
+
+export const MusdConversionInfo = () => {
+ const route =
+ useRoute, string>>();
+ const navigation = useNavigation();
+ // TEMP: Will be brought back in subsequent PR.
+ // const preferredPaymentToken = route.params?.preferredPaymentToken;
+ const outputTokenInfo = route.params?.outputToken;
+ // TEMP: Will be brought back in subsequent PR.
+ // const rawAllowedPaymentTokens = route.params?.allowedPaymentTokens;
+
+ useEffect(() => {
+ if (!outputTokenInfo) {
+ console.error(
+ '[Token Conversion] outputToken is required but was not provided in route params. Navigating back.',
+ );
+ navigation.goBack();
+ }
+ }, [outputTokenInfo, navigation]);
+
+ // TEMP: Will be brought back in subsequent PR.
+ // const allowedPaymentTokens = useMemo(() => {
+ // if (!rawAllowedPaymentTokens) {
+ // // No allowlist provided - allow all tokens
+ // return undefined;
+ // }
+
+ // if (!areValidAllowedPaymentTokens(rawAllowedPaymentTokens)) {
+ // console.warn(
+ // 'Invalid allowedPaymentTokens structure in route params. ' +
+ // 'Expected Record. Allowing all tokens.',
+ // rawAllowedPaymentTokens,
+ // );
+ // return undefined;
+ // }
+
+ // return rawAllowedPaymentTokens;
+ // }, [rawAllowedPaymentTokens]);
+
+ const tokenToAdd = outputTokenInfo || MUSD_TOKEN_MAINNET;
+
+ useNavbar(strings('earn.musd_conversion.earn_rewards_with'));
+
+ useAddToken({
+ chainId: tokenToAdd.chainId as Hex,
+ decimals: tokenToAdd.decimals,
+ name: tokenToAdd.name,
+ symbol: tokenToAdd.symbol,
+ tokenAddress: tokenToAdd.address as Hex,
+ });
+
+ if (!outputTokenInfo) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx
index 5a711afda98..e869ccf4232 100644
--- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx
+++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.tsx
@@ -24,6 +24,7 @@ import { InfoRowSkeleton, InfoRowVariant } from '../../UI/info-row/info-row';
import AlertRow from '../../UI/info-row/alert-row';
import { RowAlertKey } from '../../UI/info-row/alert-row/constants';
import { useAlerts } from '../../../context/alert-system-context';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../../../../UI/Earn/constants/musd';
import useFiatFormatter from '../../../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter';
export function BridgeFeeRow() {
@@ -110,6 +111,10 @@ function Tooltip({
message = strings('confirm.tooltip.predict_deposit.transaction_fee');
}
+ if (hasTransactionType(transactionMeta, [MUSD_CONVERSION_TRANSACTION_TYPE])) {
+ message = strings('confirm.tooltip.musd_conversion.transaction_fee');
+ }
+
switch (transactionMeta.type) {
case TransactionType.perpsDeposit:
message = strings('confirm.tooltip.perps_deposit.transaction_fee');
diff --git a/app/components/Views/confirmations/constants/confirmations.ts b/app/components/Views/confirmations/constants/confirmations.ts
index 27905996296..23f528c0a89 100644
--- a/app/components/Views/confirmations/constants/confirmations.ts
+++ b/app/components/Views/confirmations/constants/confirmations.ts
@@ -1,5 +1,6 @@
import { ApprovalType } from '@metamask/controller-utils';
import { TransactionType } from '@metamask/transaction-controller';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../../UI/Earn/constants/musd';
export const MMM_ORIGIN = 'metamask';
export const MM_MOBILE_ORIGIN = 'Metamask Mobile';
@@ -15,7 +16,8 @@ export const REDESIGNED_TRANSACTION_TYPES = [
TransactionType.deployContract,
TransactionType.lendingDeposit,
TransactionType.lendingWithdraw,
- 'perpsDeposit',
+ MUSD_CONVERSION_TRANSACTION_TYPE,
+ TransactionType.perpsDeposit,
TransactionType.revokeDelegation,
TransactionType.simpleSend,
TransactionType.stakingClaim,
@@ -46,10 +48,12 @@ export const REDESIGNED_CONTRACT_INTERACTION_TYPES = [
TransactionType.contractInteraction,
TransactionType.lendingDeposit,
TransactionType.lendingWithdraw,
+ MUSD_CONVERSION_TRANSACTION_TYPE,
TransactionType.perpsDeposit,
];
export const FULL_SCREEN_CONFIRMATIONS = [
+ MUSD_CONVERSION_TRANSACTION_TYPE,
TransactionType.perpsDeposit,
TransactionType.predictDeposit,
TransactionType.predictClaim,
diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js
index 35cf5fe4068..c9f7da13f34 100644
--- a/app/util/transactions/index.js
+++ b/app/util/transactions/index.js
@@ -69,6 +69,7 @@ import { handleMethodData } from '../../util/transaction-controller';
import EthQuery from '@metamask/eth-query';
import { EIP_7702_REVOKE_ADDRESS } from '../../components/Views/confirmations/hooks/7702/useEIP7702Accounts';
import { hasTransactionType } from '../../components/Views/confirmations/utils/transaction';
+import { MUSD_CONVERSION_TRANSACTION_TYPE } from '../../components/UI/Earn/constants/musd';
const { SAI_ADDRESS } = AppConstants;
@@ -163,6 +164,9 @@ const reviewActionKeys = {
[TransactionType.lendingWithdraw]: strings(
'transactions.tx_review_lending_withdraw',
),
+ [MUSD_CONVERSION_TRANSACTION_TYPE]: strings(
+ 'transactions.tx_review_musd_conversion',
+ ),
};
/**
@@ -215,6 +219,9 @@ const actionKeys = {
[TransactionType.predictWithdraw]: strings(
'transactions.tx_review_predict_withdraw',
),
+ [MUSD_CONVERSION_TRANSACTION_TYPE]: strings(
+ 'transactions.tx_review_musd_conversion',
+ ),
};
/**
@@ -544,6 +551,7 @@ export async function getTransactionActionKey(transaction, chainId) {
TransactionType.lendingDeposit,
TransactionType.lendingWithdraw,
TransactionType.perpsDeposit,
+ MUSD_CONVERSION_TRANSACTION_TYPE,
].includes(type)
) {
return type;
@@ -739,6 +747,7 @@ export async function getTransactionReviewActionKey(transaction, chainId) {
if (transactionReviewActionKey) {
return transactionReviewActionKey;
}
+
return actionKey;
}
diff --git a/bitrise.yml b/bitrise.yml
index 380d8b1a630..201a816baf4 100644
--- a/bitrise.yml
+++ b/bitrise.yml
@@ -3542,6 +3542,9 @@ app:
- opts:
is_expand: false
MM_PERPS_HIP3_BLOCKLIST_MARKETS: ""
+ - opts:
+ is_expand: false
+ MM_MUSD_CONVERSION_FLOW_ENABLED: false
- opts:
is_expand: false
PROJECT_LOCATION: android
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 886138d8fc9..8c71a4fb89e 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3047,6 +3047,7 @@
"swap": "Swap",
"bridge": "Bridge",
"earn": "Earn",
+ "convert": "Convert",
"tron": {
"daily_resource": "Daily resource",
"bandwidth": "Bandwidth",
@@ -3478,6 +3479,7 @@
"tx_review_predict_deposit": "Funded Predictions",
"tx_review_predict_claim": "Claimed Wins",
"tx_review_predict_withdraw": "Predictions Withdraw",
+ "tx_review_musd_conversion": "mUSD Conversion",
"sent_ether": "Sent Ether",
"self_sent_ether": "Sent Yourself Ether",
"received_ether": "Received Ether",
@@ -5467,7 +5469,16 @@
"lending": "Position history",
"staking": "Payout history"
},
- "allowance_reset": "Allowance Reset"
+ "allowance_reset": "Allowance Reset",
+ "musd_conversion": {
+ "confirmation_button": "Convert to mUSD",
+ "earn_rewards_with": "Earn rewards with mUSD",
+ "toasts": {
+ "in_progress": "mUSD conversion in progress",
+ "success": "mUSD conversion succeeded",
+ "failed": "mUSD conversion failed"
+ }
+ }
},
"stake": {
"stake": "Stake",
@@ -5749,6 +5760,9 @@
"predict_deposit": {
"transaction_fee": "We'll swap your tokens for USDC.e on Polygon, the network used by Predict. Swap providers may charge a fee, but MetaMask won't."
},
+ "musd_conversion": {
+ "transaction_fee": "mUSD conversion fees include network costs and may include provider fees."
+ },
"title": {
"transaction_fee": "Fees"
}
@@ -5860,7 +5874,8 @@
"available_balance": "Available: ",
"edit_amount_done": "Continue",
"deposit_edit_amount_done": "Add funds",
- "deposit_edit_amount_predict_withdraw": "Withdraw"
+ "deposit_edit_amount_predict_withdraw": "Withdraw",
+ "deposit_edit_amount_musd_conversion": "Convert to mUSD"
},
"change_in_simulation_modal": {
"title": "Results have changed",
From 1173bbb9025ff5df02de7626c9c7ec1a58fc5587 Mon Sep 17 00:00:00 2001
From: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Date: Fri, 21 Nov 2025 20:03:51 +0100
Subject: [PATCH 2/6] feat: [Trending] allow navigating to a website or search
on google using the omnisearch and other minor improvements (#22872)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
According to the
[these](https://www.figma.com/design/w4EHAWR5h0OTuqeqZKaCAH/Trending?node-id=2067-19515&p=f&t=6z6WJZtGvd6H9A8L-0)
designs. I have implemented the following:
Furthermore I have done some code cleanup 🧹 and 🐛 fixes:
- Added screen sliding animation to Perps
- Removed debounce from useSearchRequest
- When going back from browser we should be able to go back to search
results
- Removed pagination dots in carrousel
- Used TW in carrousel
- Updated "View all >" to match styles
- Updated search for trending tokens to merge Trending and non-trending
tokens
- Fix searchbar causing app crash due to showing and hiding element
- Show loading while isDebouncing on useExploreSearch
- Refactored sections to be react elements rather than functions
- Adjust pills padding
- Update size of section titles
- Update color of search section titles
- Add icons to pills
- Add grey arrow in view all and change "View all" color
- Change screen title to "Explore"
- Change bottom menu icon and name to "Explore"
- Change the icon to explore icon instead of plus [+]
- Make the tab number container thinner [1]
- Remove carrousel padding left to be aligned
- Remove padding from section titles
NOTE: There are still some bugs 🐛 that will be solved in upcoming PRs:
- Clicking on native token in search throws error
- Tokens from search API do not have "Volume" "Market Cap"...
## **Changelog**
CHANGELOG entry: allow navigating to a website or search on google using
the omnisearch
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1800 &
https://consensyssoftware.atlassian.net/browse/ASSETS-1801 &
https://consensyssoftware.atlassian.net/browse/ASSETS-1822
## **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/e0994d22-01d9-4a7a-a23e-4b960b1e6675
## **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]
> Adds Explore search footer to open URLs or Google results, rebrands
Trending to Explore, refactors section components/UI, and improves
navigation/animations.
>
> - **Explore/Search**:
> - Add footer actions to open direct URLs and Google searches from
`ExploreSearchResults`; navigates via `TrendingBrowser` with
`fromTrending`.
> - Integrate token search (`useSearchRequest`) with trending results;
show loading while debouncing; remove debounce delay (0ms).
> - Update `ExploreSearchBar`: autofocus by type, non-destructive clear
with opacity toggle, always-visible cancel, color tweaks.
> - **UI Refactor**:
> - Convert section config to component-based API (`RowItem`,
`Skeleton`, `Section`); update usages in `SectionCard`,
`SectionCarrousel`, `TrendingView`.
> - Redesign QuickActions to pill buttons with icons and TW styles;
adjust section headers (“View all” with arrow, sizes/colors).
> - Simplify carousel (remove pagination dots; TW spacing) and align
layouts/padding.
> - **Navigation/Browser**:
> - `BrowserTab` back behavior respects `fromTrending` to return to
search; trending browser wrapper intercepts navigation.
> - **Perps**:
> - Enable slide-in animation for Perps stack screens.
> - **Localization & Tab Bar**:
> - Rename Bottom Tab “Trending” to “Explore” and change icon
(`Search`); update `trending.title` and bottom nav strings.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
cab5efcbe0310c786b84b99770c5c524c9f66a48. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: sahar-fehri
---
.../Navigation/TabBar/TabBar.constants.ts | 2 +-
app/components/Nav/Main/MainNavigator.js | 14 +-
.../Trending/hooks/useSearchRequest/index.ts | 7 +-
.../useSearchRequest/useSearchRequest.test.ts | 12 +-
.../Views/BrowserTab/BrowserTab.tsx | 10 +-
.../ExploreSearchBar.test.tsx | 41 ++--
.../ExploreSearchBar/ExploreSearchBar.tsx | 69 +++---
.../ExploreSearchScreen.tsx | 1 -
.../ExploreSearchResults.test.tsx | 176 +++++++++++++--
.../ExploreSearchResults.tsx | 145 +++++++++---
.../config/useExploreSearch.test.ts | 10 +
.../config/useExploreSearch.ts | 9 +-
.../Views/TrendingView/TrendingView.test.tsx | 2 +-
.../Views/TrendingView/TrendingView.tsx | 87 +++-----
.../components/QuickActions/QuickActions.tsx | 33 ++-
.../components/SectionCard/SectionCard.tsx | 18 +-
.../SectionCarrousel.test.tsx | 34 +--
.../SectionCarrousel/SectionCarrousel.tsx | 209 ++++--------------
.../SectionHeader/SectionHeader.tsx | 40 ++--
.../TrendingView/config/sections.config.tsx | 87 +++++---
locales/languages/en.json | 4 +-
21 files changed, 562 insertions(+), 448 deletions(-)
diff --git a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts
index 60df364129e..2c9e1731917 100644
--- a/app/component-library/components/Navigation/TabBar/TabBar.constants.ts
+++ b/app/component-library/components/Navigation/TabBar/TabBar.constants.ts
@@ -13,7 +13,7 @@ export const ICON_BY_TAB_BAR_ICON_KEY: IconByTabBarIconKey = {
[TabBarIconKey.Activity]: IconName.Activity,
[TabBarIconKey.Setting]: IconName.Setting,
[TabBarIconKey.Rewards]: IconName.MetamaskFoxOutline,
- [TabBarIconKey.Trending]: IconName.TrendUp,
+ [TabBarIconKey.Trending]: IconName.Search,
};
export const LABEL_BY_TAB_BAR_ICON_KEY = {
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index c4b65c57dff..47ee95f3fac 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -1103,7 +1103,19 @@ const MainNavigator = () => {
name={Routes.PERPS.ROOT}
component={PerpsScreenStack}
options={{
- animationEnabled: false,
+ animationEnabled: true,
+ cardStyleInterpolator: ({ current, layouts }) => ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ },
+ }),
}}
/>
> | null>(null);
- const [isLoading, setIsLoading] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Track the current request ID to prevent stale results from overwriting current ones
@@ -88,8 +88,11 @@ export const useSearchRequest = (options: {
// Cancel any pending debounced calls from previous render
debouncedSearchTokensRequest.cancel();
+ setIsLoading(true);
+
// If query is empty, don't trigger search
if (!memoizedOptions.query) {
+ setIsLoading(false);
return;
}
diff --git a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts
index 5935f31116d..198999cb0d3 100644
--- a/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts
+++ b/app/components/UI/Trending/hooks/useSearchRequest/useSearchRequest.test.ts
@@ -104,10 +104,14 @@ describe('useSearchRequest', () => {
result.current.search();
result.current.search();
- jest.advanceTimersByTime(DEBOUNCE_WAIT - 100);
- expect(spySearchTokens).not.toHaveBeenCalled();
-
- jest.advanceTimersByTime(DEBOUNCE_WAIT + 200);
+ // Only test intermediate state if debounce wait is long enough
+ if (DEBOUNCE_WAIT > 100) {
+ jest.advanceTimersByTime(DEBOUNCE_WAIT - 100);
+ expect(spySearchTokens).not.toHaveBeenCalled();
+ jest.advanceTimersByTime(200);
+ } else {
+ jest.advanceTimersByTime(DEBOUNCE_WAIT + 100);
+ }
await Promise.resolve();
});
diff --git a/app/components/Views/BrowserTab/BrowserTab.tsx b/app/components/Views/BrowserTab/BrowserTab.tsx
index 08c53548481..ea1befdb373 100644
--- a/app/components/Views/BrowserTab/BrowserTab.tsx
+++ b/app/components/Views/BrowserTab/BrowserTab.tsx
@@ -1315,8 +1315,14 @@ export const BrowserTab: React.FC = React.memo(
);
const handleBackPress = useCallback(() => {
- navigation.navigate('TrendingFeed');
- }, [navigation]);
+ if (fromTrending) {
+ // If within trending follow the normal back button behavior
+ navigation.goBack();
+ } else {
+ // By default go to trending
+ navigation.navigate('TrendingFeed');
+ }
+ }, [navigation, fromTrending]);
const onCancelUrlBar = useCallback(() => {
hideAutocomplete();
diff --git a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
index d549370e4db..26856254d1c 100644
--- a/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchBar/ExploreSearchBar.test.tsx
@@ -46,7 +46,6 @@ describe('ExploreSearchBar', () => {
const { getByTestId, getByDisplayValue } = render(
{
const { getByTestId } = render(
{
const { getByTestId } = render(
{
expect(getByTestId('explore-search-clear-button')).toBeDefined();
});
- it('hides clear button when search query is empty', () => {
+ it('sets clear button opacity to 0 when search query is empty', () => {
const mockOnSearchChange = jest.fn();
const mockOnCancel = jest.fn();
- const { queryByTestId } = render(
+ const { getByTestId } = render(
,
);
- expect(queryByTestId('explore-search-clear-button')).toBeNull();
+ const clearButton = getByTestId('explore-search-clear-button');
+
+ expect(clearButton.props.style).toMatchObject({ opacity: 0 });
});
- it('clears search query when clear button is pressed', () => {
+ it('sets clear button opacity to 1 when search query has text', () => {
const mockOnSearchChange = jest.fn();
const mockOnCancel = jest.fn();
const { getByTestId } = render(
{
const clearButton = getByTestId('explore-search-clear-button');
- fireEvent.press(clearButton);
-
- expect(mockOnSearchChange).toHaveBeenCalledWith('');
+ expect(clearButton.props.style).toMatchObject({ opacity: 1 });
});
- it('shows cancel button when search is focused', () => {
+ it('clears search query when clear button is pressed', () => {
const mockOnSearchChange = jest.fn();
const mockOnCancel = jest.fn();
const { getByTestId } = render(
,
);
- expect(getByTestId('explore-search-cancel-button')).toBeDefined();
+ const clearButton = getByTestId('explore-search-clear-button');
+
+ fireEvent.press(clearButton);
+
+ expect(mockOnSearchChange).toHaveBeenCalledWith('');
});
- it('hides cancel button when search is not focused', () => {
+ it('shows cancel button when search is focused', () => {
const mockOnSearchChange = jest.fn();
const mockOnCancel = jest.fn();
- const { queryByTestId } = render(
+ const { getByTestId } = render(
,
);
- expect(queryByTestId('explore-search-cancel-button')).toBeNull();
+ expect(getByTestId('explore-search-cancel-button')).toBeDefined();
});
it('clears query and calls onCancel when cancel button is pressed', () => {
@@ -174,7 +171,6 @@ describe('ExploreSearchBar', () => {
const { getByTestId } = render(
{
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
- it('sets autoFocus on TextInput based on isSearchFocused prop', () => {
+ it('sets autoFocus on TextInput based on type prop', () => {
const mockOnSearchChange = jest.fn();
const mockOnCancel = jest.fn();
const { getByTestId } = render(
void;
onCancel: () => void;
@@ -64,11 +63,11 @@ const ExploreSearchBar: React.FC = (props) => {
{isButtonMode ? (
-
+
{strings('trending.search_placeholder')}
) : (
@@ -77,24 +76,28 @@ const ExploreSearchBar: React.FC = (props) => {
value={props.searchQuery}
onChangeText={props.onSearchChange}
placeholder={strings('trending.search_placeholder')}
- placeholderTextColor={colors.text.muted}
+ placeholderTextColor={colors.text.alternative}
style={tw.style('flex-1 text-base text-default py-2.5')}
testID="explore-view-search-input"
- autoFocus={props.isSearchFocused}
+ autoFocus={props.type === 'interactive'}
+ autoCapitalize="none"
/>
- {props.searchQuery && props.searchQuery.length > 0 && (
-
-
-
- )}
+ 0
+ ? 'opacity-100'
+ : 'opacity-0',
+ )}
+ >
+
+
>
)}
@@ -118,19 +121,17 @@ const ExploreSearchBar: React.FC = (props) => {
) : (
<>
{searchBarContent}
- {props.isSearchFocused && (
-
+
-
- {strings('transaction.cancel')}
-
-
- )}
+ {strings('transaction.cancel')}
+
+
>
)}
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/ExploreSearchScreen.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/ExploreSearchScreen.tsx
index ef4bc0c0cca..83e90bb215f 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/ExploreSearchScreen.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/ExploreSearchScreen.tsx
@@ -24,7 +24,6 @@ const ExploreSearchScreen: React.FC = () => {
{
jest.clearAllMocks();
});
- it('displays no results message when no data is available', () => {
- mockUseExploreSearch.mockReturnValue({
- data: {
- tokens: [],
- perps: [],
- predictions: [],
- },
- isLoading: {
- tokens: false,
- perps: false,
- predictions: false,
- },
- });
-
- const { getByTestId } = render();
-
- expect(getByTestId('trending-search-no-results')).toBeDefined();
- });
-
it('renders list when data is available', () => {
mockUseExploreSearch.mockReturnValue({
data: {
@@ -74,12 +55,9 @@ describe('ExploreSearchResults', () => {
},
});
- const { getByTestId, queryByTestId } = render(
- ,
- );
+ const { getByTestId } = render();
expect(getByTestId('trending-search-results-list')).toBeDefined();
- expect(queryByTestId('trending-search-no-results')).toBeNull();
});
it('renders section headers when sections have data', () => {
@@ -212,4 +190,154 @@ describe('ExploreSearchResults', () => {
expect(getByTestId('trending-search-results-list')).toBeDefined();
});
+
+ describe('Footer', () => {
+ it('displays Google search option when search query is provided and loading is finished', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: false,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { getByTestId, getByText } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
+ expect(getByText('bitcoin')).toBeDefined();
+ expect(getByText(/on Google/)).toBeDefined();
+ });
+
+ it('displays direct URL link when search query looks like a URL', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: false,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { getByTestId, getAllByText } = render(
+ ,
+ );
+
+ expect(getByTestId('trending-search-footer-url-link')).toBeDefined();
+ expect(getByTestId('trending-search-footer-google-link')).toBeDefined();
+ expect(getAllByText('example.com').length).toBeGreaterThan(0);
+ });
+
+ it('does not display footer when search query is empty', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [{ assetId: '1', symbol: 'BTC', name: 'Bitcoin' }],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: false,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { queryByText } = render();
+
+ expect(queryByText('Search for')).toBeNull();
+ expect(queryByText('on Google')).toBeNull();
+ });
+
+ it('does not display footer when still loading', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: true,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { queryByText } = render(
+ ,
+ );
+
+ expect(queryByText('Search for')).toBeNull();
+ expect(queryByText('on Google')).toBeNull();
+ });
+
+ it('navigates to Google search when Google search option is pressed', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: false,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const googleSearchButton = getByTestId(
+ 'trending-search-footer-google-link',
+ );
+
+ fireEvent.press(googleSearchButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'https://www.google.com/search?q=ethereum',
+ timestamp: expect.any(Number),
+ fromTrending: true,
+ });
+ });
+
+ it('navigates to URL when direct URL link is pressed', () => {
+ mockUseExploreSearch.mockReturnValue({
+ data: {
+ tokens: [],
+ perps: [],
+ predictions: [],
+ },
+ isLoading: {
+ tokens: false,
+ perps: false,
+ predictions: false,
+ },
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ const urlButton = getByTestId('trending-search-footer-url-link');
+
+ fireEvent.press(urlButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('TrendingBrowser', {
+ newTabUrl: 'example.com',
+ timestamp: expect.any(Number),
+ fromTrending: true,
+ });
+ });
+ });
});
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
index fc683663db4..216da0f5589 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
@@ -1,27 +1,26 @@
-import React, { useMemo, useCallback } from 'react';
-import { FlashList, ListRenderItem } from '@shopify/flash-list';
+import React, { useMemo, useCallback, useRef, useEffect } from 'react';
+import { TouchableOpacity } from 'react-native';
+import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
import {
Box,
- BoxAlignItems,
Text,
TextVariant,
+ Icon,
+ IconName,
+ IconSize,
} from '@metamask/design-system-react-native';
-import { strings } from '../../../../../../../locales/i18n';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
SECTIONS_CONFIG,
SECTIONS_ARRAY,
type SectionId,
} from '../../../config/sections.config';
import { useExploreSearch } from './config/useExploreSearch';
-import { StyleSheet } from 'react-native';
-
-const styles = StyleSheet.create({
- contentContainer: {
- paddingHorizontal: 16,
- },
-});
+function looksLikeUrl(str: string): boolean {
+ return /^(https?:\/\/)?[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+([/?].*)?$/.test(str);
+}
interface ExploreSearchResultsProps {
searchQuery: string;
}
@@ -49,12 +48,25 @@ const ExploreSearchResults: React.FC = ({
searchQuery,
}) => {
const navigation = useNavigation();
+ const tw = useTailwind();
const { data, isLoading } = useExploreSearch(searchQuery);
+ const flashListRef = useRef>(null);
+
+ const handlePressFooterLink = useCallback(
+ (url: string) => {
+ navigation.navigate('TrendingBrowser', {
+ newTabUrl: url,
+ timestamp: Date.now(),
+ fromTrending: true,
+ });
+ },
+ [navigation],
+ );
const renderSectionHeader = useCallback(
(title: string) => (
-
+
{title}
@@ -103,6 +115,89 @@ const ExploreSearchResults: React.FC = ({
return result;
}, [data, isLoading]);
+ // Scroll to top when search query changes
+ useEffect(() => {
+ if (flatData.length > 0) {
+ flashListRef.current?.scrollToIndex({
+ index: 0,
+ animated: false,
+ });
+ }
+ }, [searchQuery, flatData.length]);
+
+ const finishedLoading = useMemo(
+ () => Object.values(isLoading).every((value) => !value),
+ [isLoading],
+ );
+
+ const renderFooter = useMemo(() => {
+ if (!finishedLoading || searchQuery.length === 0) return null;
+
+ const isUrl = looksLikeUrl(searchQuery.toLowerCase());
+
+ return (
+
+ {isUrl && (
+ handlePressFooterLink(searchQuery)}
+ testID="trending-search-footer-url-link"
+ >
+
+ {searchQuery}
+
+
+
+ )}
+
+
+ handlePressFooterLink(
+ `https://www.google.com/search?q=${encodeURIComponent(searchQuery)}`,
+ )
+ }
+ testID="trending-search-footer-google-link"
+ >
+
+
+ Search for {'"'}
+
+
+ {searchQuery}
+
+
+ {'"'} on Google
+
+
+
+
+
+ );
+ }, [finishedLoading, searchQuery, handlePressFooterLink, tw]);
+
const renderFlatItem: ListRenderItem = useCallback(
({ item }) => {
if (item.type === 'header') {
@@ -113,11 +208,11 @@ const ExploreSearchResults: React.FC = ({
if (!section) return null;
if (item.type === 'skeleton') {
- return section.renderSkeleton();
+ return ;
}
// Cast navigation to 'never' to satisfy different navigation param list types
- return section.renderRowItem(item.data, navigation);
+ return ;
},
[navigation, renderSectionHeader],
);
@@ -131,33 +226,17 @@ const ExploreSearchResults: React.FC = ({
return section ? section.keyExtractor(item.data) : `item-${index}`;
}, []);
- if (flatData.length === 0) {
- return (
-
-
- {strings('trending.no_results')}
-
-
- );
- }
-
return (
);
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
index 6ceae654068..7aba9bc80de 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.test.ts
@@ -25,6 +25,7 @@ const mockPredictionMarkets = [
];
const mockUseTrendingRequest = jest.fn();
+const mockUseSearchRequest = jest.fn();
const mockUsePerpsMarkets = jest.fn();
const mockUsePredictMarketData = jest.fn();
@@ -32,6 +33,10 @@ jest.mock('../../../../../../UI/Trending/hooks/useTrendingRequest', () => ({
useTrendingRequest: () => mockUseTrendingRequest(),
}));
+jest.mock('../../../../../../UI/Trending/hooks/useSearchRequest', () => ({
+ useSearchRequest: () => mockUseSearchRequest(),
+}));
+
jest.mock('../../../../../../UI/Perps/hooks/usePerpsMarkets', () => ({
usePerpsMarkets: () => mockUsePerpsMarkets(),
}));
@@ -50,6 +55,11 @@ describe('useExploreSearch', () => {
isLoading: false,
});
+ mockUseSearchRequest.mockReturnValue({
+ results: mockTrendingTokens,
+ isLoading: false,
+ });
+
mockUsePerpsMarkets.mockReturnValue({
markets: mockPerpsMarkets,
isLoading: false,
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
index b63597a5ce3..376d8e53ba5 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
@@ -36,6 +36,9 @@ export const useExploreSearch = (query: string): ExploreSearchResult => {
// Fetch data for all sections using centralized hook
const allSectionsData = useSectionsData(debouncedQuery);
+ // Check if query is still debouncing (query changed but debounce hasn't completed)
+ const isDebouncing = query !== debouncedQuery;
+
const filteredResults = useMemo(() => {
const isLoading: Record = {} as Record<
SectionId,
@@ -52,7 +55,9 @@ export const useExploreSearch = (query: string): ExploreSearchResult => {
// Process each section generically
SECTIONS_ARRAY.forEach((section) => {
const sectionData = allSectionsData[section.id];
- isLoading[section.id] = sectionData.isLoading;
+ // If we're debouncing, show loading state immediately
+ // Otherwise, use the actual loading state from the data fetch
+ isLoading[section.id] = isDebouncing || sectionData.isLoading;
if (shouldShowTopItems) {
// Show top 3 items when no search query
@@ -66,7 +71,7 @@ export const useExploreSearch = (query: string): ExploreSearchResult => {
});
return { data, isLoading };
- }, [debouncedQuery, allSectionsData]);
+ }, [debouncedQuery, allSectionsData, isDebouncing]);
return filteredResults;
};
diff --git a/app/components/Views/TrendingView/TrendingView.test.tsx b/app/components/Views/TrendingView/TrendingView.test.tsx
index c50e510f149..fd5fc276aba 100644
--- a/app/components/Views/TrendingView/TrendingView.test.tsx
+++ b/app/components/Views/TrendingView/TrendingView.test.tsx
@@ -518,7 +518,7 @@ describe('TrendingView', () => {
,
);
- expect(getByText('Trending')).toBeDefined();
+ expect(getByText('Explore')).toBeDefined();
});
it('navigates to TrendingBrowser route when browser button is pressed', () => {
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index a7685473bc2..5b24fd6c0a4 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -1,16 +1,17 @@
import React, { useCallback, useMemo, useEffect } from 'react';
-import { ScrollView, StyleSheet } from 'react-native';
+import { ScrollView, TouchableOpacity } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import { createStackNavigator } from '@react-navigation/stack';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
Text,
TextVariant,
- ButtonIcon,
- ButtonIconSize,
IconName,
+ Icon,
+ IconSize,
} from '@metamask/design-system-react-native';
import { strings } from '../../../../locales/i18n';
import AppConstants from '../../../core/AppConstants';
@@ -26,7 +27,6 @@ import {
import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen';
import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar';
import {
- PredictScreenStack,
PredictModalStack,
PredictMarketDetails,
PredictSellPreview,
@@ -35,18 +35,9 @@ import PredictBuyPreview from '../../UI/Predict/views/PredictBuyPreview/PredictB
import QuickActions from './components/QuickActions/QuickActions';
import SectionHeader from './components/SectionHeader/SectionHeader';
import { HOME_SECTIONS_ARRAY } from './config/sections.config';
-import ButtonLink from '../../../component-library/components/Buttons/Button/variants/ButtonLink';
const Stack = createStackNavigator();
-const styles = StyleSheet.create({
- scrollView: {
- flex: 1,
- paddingLeft: 16,
- paddingRight: 16,
- },
-});
-
// Wrapper component to intercept navigation
const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => {
const navigation = useNavigation();
@@ -72,6 +63,7 @@ const BrowserWrapper: React.FC<{ route: object }> = ({ route }) => {
};
const TrendingFeed: React.FC = () => {
+ const tw = useTailwind();
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { isEnabled } = useMetrics();
@@ -122,38 +114,38 @@ const TrendingFeed: React.FC = () => {
-
-
-
-
-
-
-
- {browserTabsCount > 0 ? (
-
- ) : (
-
- )}
-
+
+
+
+
+
+ {browserTabsCount > 0 ? (
+
+
+ {browserTabsCount}
+
+
+ ) : (
+
+ )}
+
@@ -161,7 +153,7 @@ const TrendingFeed: React.FC = () => {
{HOME_SECTIONS_ARRAY.map((section) => (
- {section.renderSection()}
+
))}
@@ -185,17 +177,6 @@ const TrendingView: React.FC = () => {
name={Routes.EXPLORE_SEARCH}
component={ExploreSearchScreen}
/>
-
{
const navigation = useNavigation();
+ const tw = useTailwind();
return (
-
+
{SECTIONS_ARRAY.map((section) => (
- section.viewAllAction(navigation)}
testID={`quick-action-${section.id}`}
- textProps={{ variant: TextVariant.BodySm }}
+ style={tw.style(
+ 'flex-row items-center justify-center gap-1 rounded-2xl bg-background-section px-3 py-2',
+ )}
>
- {section.title}
-
+
+ {section.title}
+
))}
diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
index c63f5f62a0f..a1239da199a 100644
--- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
+++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
@@ -27,30 +27,28 @@ const SectionCard: React.FC = ({ sectionId }) => {
const theme = useAppThemeFromContext();
const styles = useMemo(() => createStyles(theme), [theme]);
- const { data, isLoading } = SECTIONS_CONFIG[sectionId].useSectionData();
+ const section = SECTIONS_CONFIG[sectionId];
+ const { data, isLoading } = section.useSectionData();
const renderFlatItem: ListRenderItem = useCallback(
- ({ item }) => {
- const section = SECTIONS_CONFIG[sectionId];
- return section.renderRowItem(item, navigation);
- },
- [navigation, sectionId],
+ ({ item }) => ,
+ [navigation, section],
);
return (
{isLoading && (
<>
- {SECTIONS_CONFIG[sectionId].renderSkeleton()}
- {SECTIONS_CONFIG[sectionId].renderSkeleton()}
- {SECTIONS_CONFIG[sectionId].renderSkeleton()}
+
+
+
>
)}
{!isLoading && (
SECTIONS_CONFIG[sectionId].keyExtractor(item)}
+ keyExtractor={(item) => section.keyExtractor(item)}
keyboardShouldPersistTaps="handled"
testID="perps-tokens-list"
/>
diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx
index c410e06700f..111bd5ebab8 100644
--- a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx
+++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx
@@ -82,17 +82,6 @@ describe('SectionCarrousel', () => {
expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
});
- it('renders pagination dots', () => {
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen();
- expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen();
- expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen();
- });
-
it('renders FlashList with sectionId as testID prefix', () => {
const { getByTestId } = renderWithProvider(
,
@@ -116,9 +105,6 @@ describe('SectionCarrousel', () => {
);
expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
- expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen();
- expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen();
- expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen();
});
it('renders actual data when isLoading is false', () => {
@@ -131,19 +117,6 @@ describe('SectionCarrousel', () => {
});
});
- describe('pagination interaction', () => {
- it('renders pressable pagination dot without errors', () => {
- const { getByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- const dot = getByTestId('predictions-pagination-dot-1');
-
- expect(dot).toBeOnTheScreen();
- });
- });
-
describe('empty data', () => {
it('renders without items when data is empty and not loading', () => {
mockUsePredictMarketData.mockReturnValue({
@@ -151,18 +124,17 @@ describe('SectionCarrousel', () => {
isFetching: false,
});
- const { queryByTestId, getByTestId } = renderWithProvider(
+ const { getByTestId } = renderWithProvider(
,
{ state: initialState },
);
expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
- expect(queryByTestId('predictions-pagination-dot-0')).toBeNull();
});
});
describe('single item', () => {
- it('renders pagination dot for single item', () => {
+ it('renders FlashList with single item', () => {
const singleItem = [createMockPredictMarket('1', 'Single Market')];
mockUsePredictMarketData.mockReturnValue({
marketData: singleItem,
@@ -174,7 +146,7 @@ describe('SectionCarrousel', () => {
{ state: initialState },
);
- expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen();
+ expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
});
});
diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx
index e262e656f13..3a389de35ea 100644
--- a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx
+++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx
@@ -1,21 +1,8 @@
-import {
- Box,
- BoxFlexDirection,
- BoxAlignItems,
- BoxJustifyContent,
- BoxBorderColor,
-} from '@metamask/design-system-react-native';
-import React, { useCallback, useRef, useState } from 'react';
-import {
- Dimensions,
- NativeScrollEvent,
- NativeSyntheticEvent,
- Pressable,
- StyleSheet,
-} from 'react-native';
+import { Box, BoxBorderColor } from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import React, { useRef } from 'react';
+import { Dimensions } from 'react-native';
import { FlashList, FlashListRef } from '@shopify/flash-list';
-import { useStyles } from '../../../../../component-library/hooks';
-import { Theme } from '../../../../../util/theme/models';
import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config';
import { useNavigation } from '@react-navigation/native';
@@ -24,177 +11,57 @@ const CONTENT_WIDTH = SCREEN_WIDTH;
const CARD_WIDTH = CONTENT_WIDTH * 0.8;
const CARD_HEIGHT = 220;
-interface SectionCarrouselStylesVars {
- activeIndex: number;
-}
-
-const styleSheet = (params: {
- theme: Theme;
- vars: SectionCarrouselStylesVars;
-}) => {
- const { theme } = params;
- const { colors } = theme;
-
- return StyleSheet.create({
- carouselItemContainer: {
- width: CARD_WIDTH,
- height: CARD_HEIGHT,
- },
- carouselItem: {
- borderRadius: 16,
- paddingHorizontal: 8,
- overflow: 'hidden',
- shadowColor: colors.shadow.default,
- },
- paginationContainer: {
- marginTop: 16,
- gap: 8,
- },
- dot: {
- height: 8,
- width: 8,
- borderRadius: 4,
- backgroundColor: colors.border.muted,
- },
- dotActive: {
- height: 8,
- width: 24,
- borderRadius: 4,
- backgroundColor: colors.text.default,
- },
- });
-};
-
export interface SectionCarrouselProps {
sectionId: SectionId;
}
const SectionCarrousel: React.FC = ({ sectionId }) => {
const navigation = useNavigation();
- const [activeIndex, setActiveIndex] = useState(0);
+ const tw = useTailwind();
const flashListRef = useRef>(null);
const section = SECTIONS_CONFIG[sectionId];
const { data, isLoading } = section.useSectionData();
- const { styles } = useStyles(styleSheet, {
- activeIndex,
- });
-
const skeletonCount = 3;
const skeletonData = Array.from({ length: skeletonCount });
- const displayDataLength = isLoading ? skeletonCount : data.length;
-
- const handleScroll = useCallback(
- (event: NativeSyntheticEvent) => {
- const scrollPosition = event.nativeEvent.contentOffset.x;
- const index = Math.round(scrollPosition / CARD_WIDTH);
- setActiveIndex(Math.min(index, displayDataLength - 1));
- },
- [displayDataLength],
- );
-
- const scrollToIndex = useCallback((index: number) => {
- flashListRef.current?.scrollToIndex({
- index,
- animated: true,
- });
- setActiveIndex(index);
- }, []);
-
- const renderSkeletonItem = useCallback(
- () => (
-
-
- {section.renderSkeleton()}
-
-
- ),
- [styles, section],
- );
-
- const renderDataItem = useCallback(
- ({ item }: { item: unknown }) => (
-
-
- {section.renderRowItem(item, navigation)}
-
-
- ),
- [styles, section, navigation],
- );
-
- const renderPaginationDots = useCallback(
- () => (
-
- {Array.from({ length: displayDataLength }).map((_, index) => {
- const isActive = activeIndex === index;
- return (
- scrollToIndex(index)}
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
- testID={`${sectionId}-pagination-dot-${index}`}
- >
-
-
- );
- })}
-
- ),
- [displayDataLength, activeIndex, scrollToIndex, styles, sectionId],
- );
+ const displayData = isLoading ? skeletonData : data;
return (
-
- {isLoading && (
- `skeleton-${index}`}
- horizontal
- pagingEnabled={false}
- showsHorizontalScrollIndicator={false}
- snapToInterval={CARD_WIDTH}
- decelerationRate="fast"
- onScroll={handleScroll}
- scrollEventThrottle={16}
- testID={`${sectionId}-flash-list`}
- />
- )}
- {!isLoading && (
- section.keyExtractor(item)}
- horizontal
- pagingEnabled={false}
- showsHorizontalScrollIndicator={false}
- snapToInterval={CARD_WIDTH}
- decelerationRate="fast"
- onScroll={handleScroll}
- scrollEventThrottle={16}
- testID={`${sectionId}-flash-list`}
- />
- )}
-
-
- {renderPaginationDots()}
+ {
+ const isLastItem = index === displayData.length - 1;
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ }}
+ keyExtractor={
+ isLoading
+ ? (_, index) => `skeleton-${index}`
+ : (item) => section.keyExtractor(item)
+ }
+ horizontal
+ pagingEnabled={false}
+ showsHorizontalScrollIndicator={false}
+ snapToInterval={CARD_WIDTH}
+ decelerationRate="fast"
+ testID={`${sectionId}-flash-list`}
+ />
);
};
diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
index 9431c91ea7b..ba1833ad81a 100644
--- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
+++ b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
@@ -1,15 +1,19 @@
import React from 'react';
-import { TouchableOpacity, StyleSheet } from 'react-native';
+import { TouchableOpacity } from 'react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
BoxFlexDirection,
BoxAlignItems,
BoxJustifyContent,
-} from '@metamask/design-system-react-native';
-import Text, {
- TextColor,
+ Icon,
+ IconName,
+ IconSize,
+ IconColor,
+ Text,
TextVariant,
-} from '../../../../../component-library/components/Texts/Text';
+ TextColor,
+} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config';
import { useNavigation } from '@react-navigation/native';
@@ -18,13 +22,6 @@ interface SectionHeaderProps {
sectionId: SectionId;
}
-const styles = StyleSheet.create({
- container: {
- paddingHorizontal: 4,
- marginBottom: 8,
- },
-});
-
/**
* Displays a section header with title and "View All" button.
* All configuration is pulled from sections.config.tsx based on the sectionId.
@@ -33,6 +30,7 @@ const styles = StyleSheet.create({
* consistency between QuickActions buttons and section "View All" buttons.
*/
const SectionHeader: React.FC = ({ sectionId }) => {
+ const tw = useTailwind();
const navigation = useNavigation();
const sectionConfig = SECTIONS_CONFIG[sectionId];
@@ -41,15 +39,21 @@ const SectionHeader: React.FC = ({ sectionId }) => {
flexDirection={BoxFlexDirection.Row}
justifyContent={BoxJustifyContent.Between}
alignItems={BoxAlignItems.Center}
- style={styles.container}
+ twClassName="mb-2"
>
-
- {sectionConfig.title}
-
- sectionConfig.viewAllAction(navigation)}>
-
+ {sectionConfig.title}
+ sectionConfig.viewAllAction(navigation)}
+ style={tw.style('flex-row items-center justify-center gap-1')}
+ >
+
{strings('trending.view_all')}
+
);
diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx
index 782ceee8e35..fd66ade2ec7 100644
--- a/app/components/Views/TrendingView/config/sections.config.tsx
+++ b/app/components/Views/TrendingView/config/sections.config.tsx
@@ -24,6 +24,8 @@ import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarket
import { usePerpsMarkets } from '../../../UI/Perps/hooks';
import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider';
import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager';
+import { useSearchRequest } from '../../../UI/Trending/hooks/useSearchRequest';
+import { IconName } from '@metamask/design-system-react-native';
export type SectionId = 'predictions' | 'tokens' | 'perps';
@@ -35,15 +37,16 @@ interface SectionData {
interface SectionConfig {
id: SectionId;
title: string;
+ icon: IconName;
viewAllAction: (navigation: NavigationProp) => void;
- renderRowItem: (
- item: unknown,
- navigation: NavigationProp,
- ) => JSX.Element;
- renderSkeleton: () => JSX.Element;
+ RowItem: React.ComponentType<{
+ item: unknown;
+ navigation: NavigationProp;
+ }>;
+ Skeleton: React.ComponentType;
getSearchableText: (item: unknown) => string;
keyExtractor: (item: unknown) => string;
- renderSection: () => JSX.Element;
+ Section: React.ComponentType;
useSectionData: (searchQuery?: string) => {
data: unknown[];
isLoading: boolean;
@@ -70,34 +73,65 @@ export const SECTIONS_CONFIG: Record = {
tokens: {
id: 'tokens',
title: strings('trending.tokens'),
+ icon: IconName.Ethereum,
viewAllAction: (navigation) => {
navigation.navigate(Routes.WALLET.TRENDING_TOKENS_FULL_VIEW);
},
- renderRowItem: (item) => (
+ RowItem: ({ item }) => (
),
- renderSkeleton: () => ,
+ Skeleton: () => ,
getSearchableText: (item) =>
`${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(),
keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`,
- renderSection: () => ,
- useSectionData: () => {
- const { results, isLoading } = useTrendingRequest({});
-
- // Apply default sorting to match full view (PriceChange, Descending)
- // This ensures the section view shows the same order as the full view
- const sortedResults = sortTrendingTokens(
- results,
- PriceChangeOption.PriceChange,
- SortDirection.Descending,
+ Section: () => ,
+ useSectionData: (searchQuery?: string) => {
+ // Trending will return tokens that have just been created which wont be picked up by search API
+ // so if you see a token on trending and search on omnisearch which uses the search endpoint...
+ // There is a chance you will get 0 results
+ const { results: searchResults, isLoading: isSearchLoading } =
+ useSearchRequest({
+ query: searchQuery || '',
+ limit: 20,
+ chainIds: [],
+ });
+
+ const { results: trendingResults, isLoading: isTrendingLoading } =
+ useTrendingRequest({});
+
+ if (!searchQuery) {
+ const sortedResults = sortTrendingTokens(
+ trendingResults,
+ PriceChangeOption.PriceChange,
+ SortDirection.Descending,
+ );
+ return {
+ data: sortedResults,
+ isLoading: isTrendingLoading,
+ };
+ }
+
+ const resultMap = new Map(
+ trendingResults.map((result) => [result.assetId, result]),
);
- return { data: sortedResults, isLoading };
+ searchResults.forEach((result) => {
+ const asset = result as TrendingAsset;
+ if (!resultMap.has(asset.assetId)) {
+ resultMap.set(asset.assetId, asset);
+ }
+ });
+
+ return {
+ data: Array.from(resultMap.values()),
+ isLoading: isSearchLoading,
+ };
},
},
perps: {
id: 'perps',
title: strings('trending.perps'),
+ icon: IconName.Candlestick,
viewAllAction: (navigation) => {
navigation.navigate(Routes.PERPS.ROOT, {
screen: Routes.PERPS.MARKET_LIST,
@@ -106,7 +140,7 @@ export const SECTIONS_CONFIG: Record = {
},
});
},
- renderRowItem: (item, navigation) => (
+ RowItem: ({ item, navigation }) => (
{
@@ -121,11 +155,11 @@ export const SECTIONS_CONFIG: Record = {
showBadge={false}
/>
),
- renderSkeleton: () => ,
+ Skeleton: () => ,
getSearchableText: (item) =>
`${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(),
keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`,
- renderSection: () => (
+ Section: () => (
@@ -141,19 +175,20 @@ export const SECTIONS_CONFIG: Record = {
predictions: {
id: 'predictions',
title: strings('wallet.predict'),
+ icon: IconName.Speedometer,
viewAllAction: (navigation) => {
navigation.navigate(Routes.PREDICT.ROOT, {
screen: Routes.PREDICT.MARKET_LIST,
});
},
- renderRowItem: (item) => (
+ RowItem: ({ item }) => (
),
- renderSkeleton: () => ,
+ Skeleton: () => ,
getSearchableText: (item) =>
(item as PredictMarketType).title.toLowerCase(),
keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`,
- renderSection: () => ,
+ Section: () => ,
useSectionData: (searchQuery?: string) => {
const { marketData, isFetching } = usePredictMarketData({
category: 'trending',
@@ -192,7 +227,7 @@ export const useSectionsData = (
searchQuery?: string,
): Record => {
const { data: trendingTokens, isLoading: isTokensLoading } =
- SECTIONS_CONFIG.tokens.useSectionData();
+ SECTIONS_CONFIG.tokens.useSectionData(searchQuery);
const { data: perpsMarkets, isLoading: isPerpsLoading } =
SECTIONS_CONFIG.perps.useSectionData();
const { data: predictionMarkets, isLoading: isPredictionsLoading } =
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 8c71a4fb89e..29e750f9be1 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -547,7 +547,7 @@
"trade": "Trade",
"settings": "Settings",
"rewards": "Rewards",
- "trending": "Trending"
+ "trending": "Explore"
},
"drawer": {
"send_button": "Send",
@@ -6927,7 +6927,7 @@
"check_network_connectivity_or": "Check your network connectivity or"
},
"trending": {
- "title": "Trending",
+ "title": "Explore",
"view_all": "View all",
"tokens": "Tokens",
"trending_tokens": "Trending Tokens",
From 9bbd6cd3a63812595208da244552a62310b1e7d7 Mon Sep 17 00:00:00 2001
From: Bryan Fullam
Date: Fri, 21 Nov 2025 16:27:46 -0300
Subject: [PATCH 3/6] fix: add staked energy and staked bandwidth to
nontradabletokens list cp-7.60.0 (#23128)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fixed a bug where staked energy and staked bandwidth were displaying in
the swap tokens lists
## **Changelog**
CHANGELOG entry: Fixed a bug where staked energy and staked bandwidth
were displaying in the swap tokens lists
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/23141
## **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]
> Switches non-tradable Tron token detection to symbol-based checks
using shared constants and updates tests accordingly.
>
> - **Bridge utils**:
> - Update `isTradableToken` to determine non-tradable Tron tokens by
`symbol` using `TRON_RESOURCE_SYMBOLS` (from
`core/Multichain/constants`) instead of name matching.
> - **Tests**:
> - Adjust `isTradableToken` tests to validate symbol-based filtering
(e.g., `energy`, `bandwidth`, `max-bandwidth`, mixed/upper case).
> - Update `useTokens` tests to use `symbol: 'Max-Bandwidth'` and verify
filtering of non-tradable Tron tokens across `tokensWithBalance`,
`topTokens`, and `remainingTokens`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9b18269db809800f941163b31322cf6bb450d1e7. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/components/UI/Bridge/hooks/useTokens.test.ts | 2 +-
.../Bridge/utils/isTradableToken/index.test.ts | 12 ++++++------
.../UI/Bridge/utils/isTradableToken/index.ts | 16 ++++++++--------
3 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/app/components/UI/Bridge/hooks/useTokens.test.ts b/app/components/UI/Bridge/hooks/useTokens.test.ts
index b98125ffbae..383a1128ab7 100644
--- a/app/components/UI/Bridge/hooks/useTokens.test.ts
+++ b/app/components/UI/Bridge/hooks/useTokens.test.ts
@@ -752,7 +752,7 @@ describe('useTokens', () => {
it('filters out non-tradable Tron tokens from remainingTokens', async () => {
const tronMaxBandwidthToken = {
address: '0x789',
- symbol: 'Max Bandwidth',
+ symbol: 'Max-Bandwidth',
name: 'Max Bandwidth',
decimals: 6,
chainId: TrxScope.Mainnet,
diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
index a5dfa33cbed..0efd75c3f7b 100644
--- a/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
+++ b/app/components/UI/Bridge/utils/isTradableToken/index.test.ts
@@ -176,7 +176,7 @@ describe('isTradableToken', () => {
it('returns false for Tron Energy token', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'Energy',
+ symbol: 'energy',
});
const result = isTradableToken(token);
@@ -187,7 +187,7 @@ describe('isTradableToken', () => {
it('returns false for Tron Bandwidth token', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'Bandwidth',
+ symbol: 'bandwidth',
});
const result = isTradableToken(token);
@@ -198,7 +198,7 @@ describe('isTradableToken', () => {
it('returns false for Tron Max Bandwidth token', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'Max Bandwidth',
+ symbol: 'max-bandwidth',
});
const result = isTradableToken(token);
@@ -209,7 +209,7 @@ describe('isTradableToken', () => {
it('returns false for Tron energy token with lowercase', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'energy',
+ symbol: 'energy',
});
const result = isTradableToken(token);
@@ -220,7 +220,7 @@ describe('isTradableToken', () => {
it('returns false for Tron bandwidth token with uppercase', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'BANDWIDTH',
+ symbol: 'BANDWIDTH',
});
const result = isTradableToken(token);
@@ -231,7 +231,7 @@ describe('isTradableToken', () => {
it('returns false for Tron max bandwidth token with mixed case', () => {
const token = createTestToken({
chainId: TrxScope.Mainnet,
- name: 'mAx BaNdWiDtH',
+ symbol: 'MaX-BaNdWiDtH',
});
const result = isTradableToken(token);
diff --git a/app/components/UI/Bridge/utils/isTradableToken/index.ts b/app/components/UI/Bridge/utils/isTradableToken/index.ts
index 8b8f3885093..4527bc7bae2 100644
--- a/app/components/UI/Bridge/utils/isTradableToken/index.ts
+++ b/app/components/UI/Bridge/utils/isTradableToken/index.ts
@@ -1,15 +1,15 @@
import { TrxScope } from '@metamask/keyring-api';
import { BridgeToken } from '../../types';
+import {
+ TRON_RESOURCE_SYMBOLS,
+ TronResourceSymbol,
+} from '../../../../../core/Multichain/constants';
export const isTradableToken = (token: BridgeToken) => {
- if (
- token.chainId === TrxScope.Mainnet &&
- (token.name?.toLowerCase() === 'energy' ||
- token.name?.toLowerCase() === 'bandwidth' ||
- token.name?.toLowerCase() === 'max bandwidth')
- ) {
- return false;
+ if (token.chainId === TrxScope.Mainnet) {
+ return !TRON_RESOURCE_SYMBOLS.includes(
+ token.symbol?.toLowerCase() as TronResourceSymbol,
+ );
}
-
return true;
};
From eca47cdb86eec65c97c61d653c5bca15490dadaf Mon Sep 17 00:00:00 2001
From: tommasini <46944231+tommasini@users.noreply.github.com>
Date: Fri, 21 Nov 2025 19:28:20 +0000
Subject: [PATCH 4/6] chore: add app metadata controller enabled on sentry app
start (#23127)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
## **Changelog**
CHANGELOG entry:
## **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]
> Expands Sentry app state mask to include
`engine.backgroundState.AppMetadataController` (all properties) and
`user.existingUser`.
>
> - **Sentry utils (`app/util/sentry/utils.ts`)**:
> - Expand `sentryStateMask`:
> - Add `engine.backgroundState.AppMetadataController` with
`[AllProperties]: true`.
> - Add `user.existingUser: true`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
a3ac4838c4a09d015aace204bc57ec857c38b8e6. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
app/util/sentry/utils.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/util/sentry/utils.ts b/app/util/sentry/utils.ts
index 683c70f6275..5bcee97dbb9 100644
--- a/app/util/sentry/utils.ts
+++ b/app/util/sentry/utils.ts
@@ -76,6 +76,9 @@ export const sentryStateMask = {
AddressBookController: {
[AllProperties]: false,
},
+ AppMetadataController: {
+ [AllProperties]: true,
+ },
ApprovalController: {
[AllProperties]: false,
},
@@ -253,6 +256,7 @@ export const sentryStateMask = {
protectWalletModalVisible: true,
seedphraseBackedUp: true,
userLoggedIn: true,
+ existingUser: true,
},
} as Record;
From 837771d588951c6fa493a2344fbba389d2ebc53e Mon Sep 17 00:00:00 2001
From: Vince Howard
Date: Fri, 21 Nov 2025 14:36:18 -0700
Subject: [PATCH 5/6] fix: earn banner border and background are wrong colors
(#22275)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Earn banner border and background are wrong colors
## **Changelog**
CHANGELOG entry:null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MDP-398
## **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]
> Switch Earn banner and Stake EstimatedAnnualRewardsCard to muted
backgrounds without borders; update snapshots accordingly.
>
> - **UI**:
> - `EarnInputView` banner: set background to `#3c4d9d0f`; remove
`borderWidth`/`borderColor`.
> - `Stake/components/EstimatedAnnualRewardsCard`: use
`colors.background.muted`; remove border styling.
> - **Tests**:
> - Update snapshots in `EarnInputView.test.tsx.snap` to reflect new
styles.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ccc568990e219e62b4448c145158f18b9521e75b. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../__snapshots__/EarnInputView.test.tsx.snap | 8 ++------
.../UI/Stake/components/EstimatedAnnualRewardsCard.tsx | 4 +---
2 files changed, 3 insertions(+), 9 deletions(-)
diff --git a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
index 9f78bf56e26..e104277e60d 100644
--- a/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
+++ b/app/components/UI/Earn/Views/EarnInputView/__snapshots__/EarnInputView.test.tsx.snap
@@ -567,10 +567,8 @@ exports[`EarnInputView render matches snapshot 1`] = `
style={
{
"alignItems": "center",
- "backgroundColor": "#ffffff",
- "borderColor": "#b7bbc8",
+ "backgroundColor": "#3c4d9d0f",
"borderRadius": 8,
- "borderWidth": 1,
"justifyContent": "center",
"paddingHorizontal": 16,
"paddingVertical": 8,
@@ -2166,10 +2164,8 @@ exports[`EarnInputView when values are entered in the keypad updates ETH and fia
style={
{
"alignItems": "center",
- "backgroundColor": "#ffffff",
- "borderColor": "#b7bbc8",
+ "backgroundColor": "#3c4d9d0f",
"borderRadius": 8,
- "borderWidth": 1,
"justifyContent": "center",
"paddingHorizontal": 16,
"paddingVertical": 8,
diff --git a/app/components/UI/Stake/components/EstimatedAnnualRewardsCard.tsx b/app/components/UI/Stake/components/EstimatedAnnualRewardsCard.tsx
index 92cc7b63ba3..580437e4a47 100644
--- a/app/components/UI/Stake/components/EstimatedAnnualRewardsCard.tsx
+++ b/app/components/UI/Stake/components/EstimatedAnnualRewardsCard.tsx
@@ -17,12 +17,10 @@ import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
const createStyles = (colors: Colors) =>
StyleSheet.create({
rewardCard: {
- backgroundColor: colors.background.default,
- borderColor: colors.border.default,
+ backgroundColor: colors.background.muted,
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 8,
- borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
From 7fd610be8b328dacdf89c41098b4a53790fc5c4e Mon Sep 17 00:00:00 2001
From: Bruno Nascimento
Date: Fri, 21 Nov 2025 18:47:28 -0300
Subject: [PATCH 6/6] fix(card): cp-7.60.0 Card Onboarding flow refactor
(#22976)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR refactors the Card onboarding flow to improve the KYC
verification process and user experience:
- **New VerifyingRegistration screen**: Adds a dedicated screen showing
users their registration is being verified
- **Enhanced navigation**: Restructures onboarding navigator with
`cardUserPhase` parameter, removes `Complete` screen, implements proper
navigation resets
- **Improved KYC polling**: Better polling lifecycle management with
manual `startPolling` control and automatic stopping on completion
- **Authentication UI refactor**: Updates `CardAuthentication` to use
`KeyboardAwareScrollView` and Tailwind styles
- **Code cleanup**: Replaces `useIsCardholder` hook with
`selectHasCardholderAccounts` selector
- **SDK enhancements**: Adds `getUserDetails` method and exposes
`fetchUserData` in CardSDKProvider
- **Bug fix**: Fixes `validateDateOfBirth` to accept negative timestamps
(dates before 1970)
## **Changelog**
CHANGELOG entry: Improved Card onboarding flow with new verification
screen and enhanced KYC process navigation
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: Card Onboarding Flow
Scenario: user completes card onboarding with address verification
Given user is on the Card welcome screen
And user has not completed onboarding
When user proceeds through authentication
And user enters their physical address
And user confirms their mailing address
Then user should see the Verifying Registration screen
And KYC verification polling should start automatically
Scenario: user returns to app during verification
Given user has submitted their registration details
And KYC verification is pending
When user opens the app
Then user should be directed to the Verifying Registration screen
And polling should resume to check verification status
Scenario: user completes KYC verification
Given user is on the Verifying Registration screen
And KYC verification completes successfully
When the polling detects verification completion
Then user should be navigated to the appropriate next screen
And polling should stop automatically
```
## **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]
> Refactors Card onboarding/KYC with a new Verifying Registration screen
and polling, KYC-gated home actions and alerts, SDK/user data
enhancements, updated navigation, and UI/auth improvements.
>
> - **Onboarding/KYC Flow**:
> - Add `VerifyingRegistration` screen with polling and
success/timeout/rejected states; new route
`Routes.CARD.VERIFYING_REGISTRATION`.
> - Refactor `OnboardingNavigator` to use `cardUserPhase`, fetch user on
mount, remove `Complete`, and rework initial routing.
> - Update navigation to reset stacks after phone/mailing/physical
address steps.
> - Make `ValidatingKYC` start/stop polling via
`useUserRegistrationStatus` (manual start, auto-stop on terminal
states).
> - **Card Home**:
> - Gate “Enable card” by KYC; show status/error alerts; integrate new
`useGetUserKYCStatus` hook.
> - **Authentication/UI**:
> - Refactor `CardAuthentication` to `KeyboardAwareScrollView` and
Tailwind styles; minor styles cleanup.
> - `CardWelcome` switches to `selectHasCardholderAccounts` for copies
and routing.
> - **SDK/Provider**:
> - Add `CardSDK.getUserDetails` and expose `fetchUserData` in provider;
reset onboarding on invalid ID.
> - **Hooks**:
> - New `useGetUserKYCStatus`; updates across hooks to use SDK context
shape; caching fetchOnMount=false with manual triggers in multiple
hooks.
> - **Validation**:
> - Fix `validateDateOfBirth` to accept negative timestamps (pre‑1970).
> - **Localization**:
> - Add strings for verifying registration and KYC status alerts.
> - **Routes/Selectors**:
> - Add `Routes.CARD.VERIFYING_REGISTRATION`; new selector
`selectHasCardholderAccounts`; deprecate `useIsCardholder`.
> - **Tests**:
> - Extensive updates/additions covering new screens, KYC gating,
navigator logic, hooks, SDK, and DOB validation.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
73e431e7aaa9f7ea83fa7cb6d5f949d6eeeae128. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
.../CardAuthentication.styles.ts | 2 -
.../CardAuthentication/CardAuthentication.tsx | 284 +++---
.../CardAuthentication.test.tsx.snap | 887 ++++++++---------
.../UI/Card/Views/CardHome/CardHome.test.tsx | 523 ++++++++++
.../UI/Card/Views/CardHome/CardHome.tsx | 154 ++-
.../Views/CardWelcome/CardWelcome.test.tsx | 152 ++-
.../UI/Card/Views/CardWelcome/CardWelcome.tsx | 13 +-
.../Onboarding/ConfirmPhoneNumber.test.tsx | 62 +-
.../Onboarding/ConfirmPhoneNumber.tsx | 5 +-
.../Onboarding/MailingAddress.test.tsx | 46 +-
.../components/Onboarding/MailingAddress.tsx | 8 +-
.../Onboarding/PhysicalAddress.test.tsx | 48 +-
.../components/Onboarding/PhysicalAddress.tsx | 7 +-
.../Onboarding/ValidatingKYC.test.tsx | 65 +-
.../components/Onboarding/ValidatingKYC.tsx | 10 +-
.../Onboarding/VerifyingRegistration.test.tsx | 920 ++++++++++++++++++
.../Onboarding/VerifyingRegistration.tsx | 366 +++++++
app/components/UI/Card/constants.ts | 1 +
.../UI/Card/hooks/useCardDelegation.test.ts | 10 +-
.../UI/Card/hooks/useCardDetails.test.ts | 23 +-
.../UI/Card/hooks/useCardDetails.ts | 23 +-
.../useCardProviderAuthentication.test.ts | 15 +-
.../hooks/useCardProviderAuthentication.ts | 12 +-
.../UI/Card/hooks/useCardProvision.test.ts | 21 +-
.../hooks/useEmailVerificationSend.test.ts | 16 +-
.../hooks/useEmailVerificationVerify.test.ts | 16 +-
.../useGetCardExternalWalletDetails.test.ts | 26 +-
.../hooks/useGetDelegationSettings.test.ts | 25 +-
.../Card/hooks/useGetUserKYCStatus.test.tsx | 187 ++++
.../UI/Card/hooks/useGetUserKYCStatus.ts | 76 ++
.../UI/Card/hooks/useIsCardholder.test.ts | 95 --
.../UI/Card/hooks/useIsCardholder.ts | 9 -
.../UI/Card/hooks/useLoadCardData.test.ts | 366 ++++++-
.../UI/Card/hooks/useLoadCardData.ts | 39 +-
.../hooks/usePhoneVerificationSend.test.ts | 23 +-
.../hooks/usePhoneVerificationVerify.test.ts | 16 +-
.../hooks/useRegisterMailingAddress.test.ts | 20 +-
.../hooks/useRegisterPersonalDetails.test.ts | 26 +-
.../hooks/useRegisterPhysicalAddress.test.ts | 20 +-
.../Card/hooks/useRegisterUserConsent.test.ts | 65 +-
.../hooks/useRegistrationSettings.test.ts | 15 +-
.../Card/hooks/useStartVerification.test.ts | 45 +-
.../Card/hooks/useUpdateTokenPriority.test.ts | 10 +-
.../hooks/useUserRegistrationStatus.test.ts | 201 ++--
.../Card/hooks/useUserRegistrationStatus.ts | 17 +-
.../Card/routes/OnboardingNavigator.test.tsx | 417 +++++++-
.../UI/Card/routes/OnboardingNavigator.tsx | 103 +-
app/components/UI/Card/routes/index.tsx | 6 +
app/components/UI/Card/sdk/CardSDK.test.ts | 245 +++++
app/components/UI/Card/sdk/CardSDK.ts | 39 +
app/components/UI/Card/sdk/index.test.tsx | 72 ++
app/components/UI/Card/sdk/index.tsx | 63 +-
app/components/UI/Card/types.ts | 40 +-
app/components/UI/Card/util/metrics.ts | 3 +
.../UI/Card/util/validateDateOfBirth.test.ts | 78 +-
.../UI/Card/util/validateDateOfBirth.ts | 7 +-
app/constants/navigation/Routes.ts | 1 +
app/core/redux/slices/card/index.ts | 5 +
locales/languages/en.json | 36 +
59 files changed, 4745 insertions(+), 1340 deletions(-)
create mode 100644 app/components/UI/Card/components/Onboarding/VerifyingRegistration.test.tsx
create mode 100644 app/components/UI/Card/components/Onboarding/VerifyingRegistration.tsx
create mode 100644 app/components/UI/Card/hooks/useGetUserKYCStatus.test.tsx
create mode 100644 app/components/UI/Card/hooks/useGetUserKYCStatus.ts
delete mode 100644 app/components/UI/Card/hooks/useIsCardholder.test.ts
delete mode 100644 app/components/UI/Card/hooks/useIsCardholder.ts
diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.styles.ts b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.styles.ts
index 3d37fa59e23..ea480d29cca 100644
--- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.styles.ts
+++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.styles.ts
@@ -12,13 +12,11 @@ const createStyles = (theme: Theme) =>
container: {
flex: 1,
backgroundColor: theme.colors.background.default,
- paddingHorizontal: 16,
},
containerSpaceAround: {
flex: 1,
backgroundColor: theme.colors.background.default,
justifyContent: 'space-around',
- paddingHorizontal: 16,
},
title: {
marginTop: 24,
diff --git a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
index 5c141c82668..5311fb82542 100644
--- a/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
+++ b/app/components/UI/Card/Views/CardAuthentication/CardAuthentication.tsx
@@ -2,9 +2,7 @@ import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Image,
- KeyboardAvoidingView,
Platform,
- ScrollView,
TouchableOpacity,
View,
TextInput,
@@ -55,6 +53,8 @@ import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { useDispatch } from 'react-redux';
import { setOnboardingId } from '../../../../../core/redux/slices/card';
import { CardActions, CardScreens } from '../../util/metrics';
+import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
const CELL_COUNT = 6;
const autoComplete = Platform.select({
@@ -118,9 +118,9 @@ const CardAuthentication = () => {
clearOtpError,
otpLoading,
} = useCardProviderAuthentication();
-
const styles = createStyles(theme);
const { styles: otpStyles } = useStyles(createOtpStyles, {});
+ const tw = useTailwind();
const handleEmailChange = (newEmail: string) => {
setEmail(newEmail);
@@ -240,13 +240,17 @@ const CardAuthentication = () => {
return;
}
- if (
- loginResponse?.verificationState === 'PENDING' ||
- loginResponse?.phase
- ) {
- // Switch to OTP step instead of navigating
+ if (loginResponse?.phase) {
dispatch(setOnboardingId(loginResponse.userId));
- navigation.navigate(Routes.CARD.ONBOARDING.ROOT);
+ navigation.reset({
+ index: 0,
+ routes: [
+ {
+ name: Routes.CARD.ONBOARDING.ROOT,
+ params: { cardUserPhase: loginResponse.phase },
+ },
+ ],
+ });
return;
}
@@ -326,154 +330,136 @@ const CardAuthentication = () => {
clearOtpError();
}, [clearOtpError]);
- // Render OTP step
- if (step === 'otp') {
- return (
-
+
-
-
-
-
-
-
+
+
+
+
+
+ {strings('card.card_otp_authentication.title')}
+
+
+ {otpData?.maskedPhoneNumber
+ ? strings(
+ 'card.card_otp_authentication.description_with_phone_number',
+ { maskedPhoneNumber: otpData.maskedPhoneNumber },
+ )
+ : strings(
+ 'card.card_otp_authentication.description_without_phone_number',
+ )}
+
+
+
+
+ }
+ {...props}
+ value={confirmCode}
+ onChangeText={handleOtpValueChange}
+ cellCount={CELL_COUNT}
+ rootStyle={otpStyles.codeFieldRoot}
+ keyboardType="number-pad"
+ textContentType="oneTimeCode"
+ autoComplete={autoComplete}
+ renderCell={({ index, symbol, isFocused }) => (
+
+
+ {symbol || (isFocused ? : null)}
+
+
+ )}
/>
-
- {strings('card.card_otp_authentication.title')}
-
-
- {otpData?.maskedPhoneNumber
- ? strings(
- 'card.card_otp_authentication.description_with_phone_number',
- { maskedPhoneNumber: otpData.maskedPhoneNumber },
- )
- : strings(
- 'card.card_otp_authentication.description_without_phone_number',
- )}
-
-
-
-
- }
- {...props}
- value={confirmCode}
- onChangeText={handleOtpValueChange}
- cellCount={CELL_COUNT}
- rootStyle={otpStyles.codeFieldRoot}
- keyboardType="number-pad"
- textContentType="oneTimeCode"
- autoComplete={autoComplete}
- renderCell={({ index, symbol, isFocused }) => (
-
-
- {symbol || (isFocused ? : null)}
-
-
- )}
- />
+ {otpError && (
+
+
+ {otpError}
+
- {otpError && (
-
-
- {otpError}
-
-
- )}
-
- {resendCountdown > 0 ? (
+ )}
+
+ {resendCountdown > 0 ? (
+
+ {strings('card.card_otp_authentication.resend_timer', {
+ seconds: resendCountdown,
+ })}
+
+ ) : (
+
- {strings('card.card_otp_authentication.resend_timer', {
- seconds: resendCountdown,
- })}
+ {strings('card.card_otp_authentication.resend_code')}
- ) : (
-
-
- {strings('card.card_otp_authentication.resend_code')}
-
-
- )}
-
-
-
-
-
-
-
-
- );
- }
-
- // Render login step
- return (
-
-
-
+
+ performLogin(confirmCode)}
+ loading={loading || otpLoading}
+ disabled={isOtpDisabled}
+ width={ButtonWidthTypes.Full}
+ />
+
+
+
+ ) : (
{
-
-
-
+ )}
+
+
);
};
diff --git a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap
index 66b570a403d..e37517bbc5e 100644
--- a/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap
+++ b/app/components/UI/Card/Views/CardAuthentication/__snapshots__/CardAuthentication.test.tsx.snap
@@ -312,44 +312,75 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l
}
}
>
-
-
-
+
-
+ >
-
+
+
+ Log in to your Card account
+
+
+
-
-
-
- Log in to your Card account
-
-
+ International
+
+
+
-
-
-
- International
-
-
-
+
-
- 🇺🇸
-
-
- US account
-
-
-
+ ],
+ ]
+ }
+ >
+ US account
+
+
+
+
-
-
- Email
-
-
+
-
-
-
-
-
-
-
- Password
-
-
-
-
+ value=""
+ />
@@ -797,80 +700,194 @@ exports[`CardAuthentication Component Login Step - Component Rendering matches l
]
}
>
-
+ Password
+
+
-
- Log in
-
-
-
-
- card.card_authentication.signup_button
-
-
+ "lineHeight": 20,
+ "opacity": 1,
+ "paddingVertical": 2,
+ "textAlignVertical": "center",
+ }
+ }
+ value=""
+ />
+
+
+
+
+
+ Log in
+
+
+
+
+ card.card_authentication.signup_button
+
+
+
-
-
-
+
+
+
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
index d83f35b5c5d..b43f2bb6899 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
@@ -390,6 +390,22 @@ jest.mock('../../../../../../locales/i18n', () => ({
'Are you sure you want to logout?',
'card.card_home.logout_confirmation_cancel': 'Cancel',
'card.card_home.logout_confirmation_confirm': 'Logout',
+ 'card.card_home.kyc_status.pending.title': 'Verification in Progress',
+ 'card.card_home.kyc_status.pending.description':
+ 'Your identity verification is being processed. This usually takes a few minutes. Please check back shortly to enable your card.',
+ 'card.card_home.kyc_status.rejected.title': 'Verification Not Approved',
+ 'card.card_home.kyc_status.rejected.description':
+ 'We were unable to verify your identity. Please contact support for assistance.',
+ 'card.card_home.kyc_status.rejected.support_description':
+ "We were unable to verify your identity at this time. Please contact our support team for assistance and we'll help you resolve this issue.",
+ 'card.card_home.kyc_status.unverified.title': 'Verification Required',
+ 'card.card_home.kyc_status.unverified.description':
+ 'You need to complete identity verification before enabling your card. Please complete the onboarding process.',
+ 'card.card_home.kyc_status.error.title':
+ 'Verification Status Unavailable',
+ 'card.card_home.kyc_status.error.description':
+ "We couldn't check your verification status. Please try again later or contact support if the issue persists.",
+ 'card.card_home.kyc_status.ok_button': 'OK',
};
return strings[key] || key;
},
@@ -483,6 +499,15 @@ function setupLoadCardDataMock(
isAuthenticated: boolean;
isBaanxLoginEnabled: boolean;
isCardholder: boolean;
+ kycStatus: {
+ verificationState:
+ | 'VERIFIED'
+ | 'PENDING'
+ | 'REJECTED'
+ | 'UNVERIFIED'
+ | null;
+ userId: string;
+ } | null;
}>,
) {
const defaults = {
@@ -495,6 +520,7 @@ function setupLoadCardDataMock(
isAuthenticated: false,
isBaanxLoginEnabled: true,
isCardholder: true,
+ kycStatus: { verificationState: 'VERIFIED' as const, userId: 'user-123' },
};
const config = { ...defaults, ...overrides };
@@ -2754,4 +2780,501 @@ describe('CardHome Component', () => {
});
});
});
+
+ describe('KYC Status Verification', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(Alert, 'alert').mockImplementation(jest.fn());
+ });
+
+ describe('canEnableCard Logic', () => {
+ it('enables card button when user is verified and authenticated', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(false);
+ });
+
+ it('disables card button when user KYC is pending', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(true);
+ });
+
+ it('disables card button when user KYC is rejected', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'REJECTED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(true);
+ });
+
+ it('disables card button when user KYC is unverified', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'UNVERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(true);
+ });
+
+ it('disables card button when KYC status is loading', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: true,
+ });
+
+ render();
+
+ // When loading, the button skeleton is shown instead
+ expect(
+ screen.getByTestId(CardHomeSelectors.ADD_FUNDS_BUTTON_SKELETON),
+ ).toBeTruthy();
+ expect(
+ screen.queryByTestId(CardHomeSelectors.ENABLE_CARD_BUTTON),
+ ).toBeNull();
+ });
+
+ it('disables card button when KYC status is null', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(true);
+ });
+
+ it('enables card button for unauthenticated users regardless of KYC', () => {
+ setupMockSelectors({ isAuthenticated: false });
+ setupLoadCardDataMock({
+ isAuthenticated: false,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ isLoading: false,
+ });
+
+ render();
+
+ const enableButton = screen.getByTestId(
+ CardHomeSelectors.ENABLE_CARD_BUTTON,
+ );
+ expect(enableButton.props.disabled).toBe(false);
+ });
+
+ it('enables card button when Baanx login is disabled', () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: false,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ // When Baanx login is disabled, should show add funds button instead
+ expect(
+ screen.getByTestId(CardHomeSelectors.ADD_FUNDS_BUTTON),
+ ).toBeTruthy();
+ });
+ });
+
+ describe('KYC Status Alerts', () => {
+ it('displays alert when KYC status is pending', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Verification in Progress',
+ 'Your identity verification is being processed. This usually takes a few minutes. Please check back shortly to enable your card.',
+ [{ text: 'OK', style: 'default' }],
+ );
+ });
+ });
+
+ it('displays alert when KYC status is rejected', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'REJECTED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Verification Not Approved',
+ "We were unable to verify your identity at this time. Please contact our support team for assistance and we'll help you resolve this issue.",
+ [{ text: 'OK', style: 'default' }],
+ );
+ });
+ });
+
+ it('displays alert when KYC status is unverified', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'UNVERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Verification Required',
+ 'You need to complete identity verification before enabling your card. Please complete the onboarding process.',
+ [{ text: 'OK', style: 'default' }],
+ );
+ });
+ });
+
+ it('does not display alert when KYC status is verified', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not display alert when user is unauthenticated', async () => {
+ setupMockSelectors({ isAuthenticated: false });
+ setupLoadCardDataMock({
+ isAuthenticated: false,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not display alert when Baanx login is disabled', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: false,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not display alert when warning is not NoCard', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NeedDelegation,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not display alert when data is loading', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: true,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('KYC Error Handling', () => {
+ it('displays error alert when KYC fetch fails', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ error: 'KYC fetch failed',
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Verification Status Unavailable',
+ "We couldn't check your verification status. Please try again later or contact support if the issue persists.",
+ [{ text: 'OK', style: 'default' }],
+ );
+ });
+ });
+
+ it('does not display error alert when KYC status exists', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ error: 'Some other error',
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalledWith(
+ 'Verification Status Unavailable',
+ expect.any(String),
+ expect.any(Array),
+ );
+ });
+ });
+
+ it('does not display error alert when user is unauthenticated', async () => {
+ setupMockSelectors({ isAuthenticated: false });
+ setupLoadCardDataMock({
+ isAuthenticated: false,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ error: 'KYC fetch failed',
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalledWith(
+ 'Verification Status Unavailable',
+ expect.any(String),
+ expect.any(Array),
+ );
+ });
+ });
+
+ it('does not display error alert when data is loading', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: null,
+ error: 'KYC fetch failed',
+ isLoading: true,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('getKYCStatusMessage Function', () => {
+ it('returns correct message for PENDING state', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ expect.stringContaining('Progress'),
+ expect.stringContaining('being processed'),
+ expect.any(Array),
+ );
+ });
+ });
+
+ it('returns correct message for REJECTED state', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'REJECTED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ expect.stringContaining('Not Approved'),
+ expect.stringContaining('contact our support team'),
+ expect.any(Array),
+ );
+ });
+ });
+
+ it('returns correct message for UNVERIFIED state', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'UNVERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ expect.stringContaining('Required'),
+ expect.stringContaining('complete identity verification'),
+ expect.any(Array),
+ );
+ });
+ });
+
+ it('returns null for VERIFIED state', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(Alert.alert).not.toHaveBeenCalled();
+ });
+ });
+
+ it('returns null for null verification state', async () => {
+ setupMockSelectors({ isAuthenticated: true });
+ setupLoadCardDataMock({
+ isAuthenticated: true,
+ isBaanxLoginEnabled: true,
+ warning: CardWarning.NoCard,
+ kycStatus: { verificationState: null, userId: 'user-123' },
+ isLoading: false,
+ });
+
+ render();
+
+ await waitFor(() => {
+ const statusAlerts = (Alert.alert as jest.Mock).mock.calls.filter(
+ (call) =>
+ call[0].includes('Progress') ||
+ call[0].includes('Not Approved') ||
+ call[0].includes('Required'),
+ );
+ expect(statusAlerts).toHaveLength(0);
+ });
+ });
+ });
+ });
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 4d00c8618cf..7386c3f7d01 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -39,7 +39,13 @@ import Button, {
} from '../../../../../component-library/components/Buttons/Button';
import { strings } from '../../../../../../locales/i18n';
import { useNavigateToCardPage } from '../../hooks/useNavigateToCardPage';
-import { AllowanceState, CardStatus, CardType, CardWarning } from '../../types';
+import {
+ AllowanceState,
+ CardStatus,
+ CardType,
+ CardVerificationState,
+ CardWarning,
+} from '../../types';
import CardAssetItem from '../../components/CardAssetItem';
import ManageCardListItem from '../../components/ManageCardListItem';
import CardImage from '../../components/CardImage';
@@ -53,6 +59,7 @@ import { useOpenSwaps } from '../../hooks/useOpenSwaps';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
import { Skeleton } from '../../../../../component-library/components/Skeleton';
import {
+ CARD_SUPPORT_EMAIL,
DEPOSIT_SUPPORTED_TOKENS,
SPENDING_LIMIT_UNSUPPORTED_TOKENS,
} from '../../constants';
@@ -102,6 +109,8 @@ const CardHome = () => {
const hasLoadedCardHomeView = useRef(false);
const hasHandledAuthErrorRef = useRef(false);
const isComponentUnmountedRef = useRef(false);
+ const hasShownKYCAlertRef = useRef(false);
+ const hasShownKYCErrorAlertRef = useRef(false);
const [
isCloseSpendingLimitWarningShown,
setIsCloseSpendingLimitWarningShown,
@@ -132,6 +141,7 @@ const CardHome = () => {
allTokens,
delegationSettings,
externalWalletDetailsData,
+ kycStatus,
} = useLoadCardData();
const assetBalancesMap = useAssetBalances(
@@ -359,6 +369,45 @@ const CardHome = () => {
[priorityTokenWarning],
);
+ // Determine if card can be enabled based on KYC status
+ const canEnableCard = useMemo(() => {
+ if (!isAuthenticated || !isBaanxLoginEnabled) {
+ return true; // No KYC check for unauthenticated users
+ }
+
+ if (!kycStatus || isLoading) {
+ return false; // Wait for KYC status to load
+ }
+
+ return kycStatus.verificationState === 'VERIFIED';
+ }, [isAuthenticated, isBaanxLoginEnabled, kycStatus, isLoading]);
+
+ const getKYCStatusMessage = useCallback(
+ (verificationState: CardVerificationState | null | undefined) => {
+ switch (verificationState) {
+ case 'PENDING':
+ return {
+ title: strings('card.card_home.kyc_status.pending.title'),
+ description: strings(
+ 'card.card_home.kyc_status.pending.description',
+ { email: CARD_SUPPORT_EMAIL },
+ ),
+ };
+ case 'UNVERIFIED':
+ return {
+ title: strings('card.card_home.kyc_status.unverified.title'),
+ description: strings(
+ 'card.card_home.kyc_status.unverified.description',
+ { email: CARD_SUPPORT_EMAIL },
+ ),
+ };
+ default:
+ return null;
+ }
+ },
+ [],
+ );
+
const enableCardAction = useCallback(async () => {
try {
await provisionCard();
@@ -413,7 +462,8 @@ const CardHome = () => {
disabled={
isLoading ||
isLoadingPollCardStatusUntilProvisioned ||
- isLoadingProvisionCard
+ isLoadingProvisionCard ||
+ !canEnableCard
}
loading={
isLoading ||
@@ -495,6 +545,7 @@ const CardHome = () => {
needToEnableAssets,
needToEnableCard,
styles,
+ canEnableCard,
]);
useEffect(
@@ -567,6 +618,105 @@ const CardHome = () => {
}
}, [fetchAllData, isAuthenticated]);
+ // Show KYC status alert if needed
+ useEffect(() => {
+ if (
+ !isAuthenticated ||
+ !isBaanxLoginEnabled ||
+ !needToEnableCard ||
+ !kycStatus ||
+ isLoading
+ ) {
+ return;
+ }
+
+ // Prevent showing the alert multiple times
+ if (hasShownKYCAlertRef.current) {
+ return;
+ }
+
+ const verificationState = kycStatus.verificationState;
+
+ // Handle REJECTED separately with support message
+ if (verificationState === 'REJECTED') {
+ hasShownKYCAlertRef.current = true;
+ Alert.alert(
+ strings('card.card_home.kyc_status.rejected.title'),
+ strings('card.card_home.kyc_status.rejected.support_description', {
+ email: CARD_SUPPORT_EMAIL,
+ }),
+ [
+ {
+ text: strings('card.card_home.kyc_status.ok_button'),
+ style: 'default',
+ },
+ ],
+ );
+ return;
+ }
+
+ // Handle PENDING and UNVERIFIED
+ if (
+ verificationState &&
+ ['PENDING', 'UNVERIFIED'].includes(verificationState)
+ ) {
+ const message = getKYCStatusMessage(verificationState);
+ if (message) {
+ hasShownKYCAlertRef.current = true;
+ Alert.alert(message.title, message.description, [
+ {
+ text: strings('card.card_home.kyc_status.ok_button'),
+ style: 'default',
+ },
+ ]);
+ }
+ }
+ }, [
+ kycStatus,
+ isAuthenticated,
+ isBaanxLoginEnabled,
+ needToEnableCard,
+ getKYCStatusMessage,
+ isLoading,
+ ]);
+
+ // Handle KYC error separately
+ useEffect(() => {
+ if (
+ isAuthenticated &&
+ isBaanxLoginEnabled &&
+ needToEnableCard &&
+ cardError &&
+ kycStatus === null &&
+ !isLoading
+ ) {
+ // Prevent showing the error alert multiple times
+ if (hasShownKYCErrorAlertRef.current) {
+ return;
+ }
+
+ // KYC status fetch failed
+ hasShownKYCErrorAlertRef.current = true;
+ Alert.alert(
+ strings('card.card_home.kyc_status.error.title'),
+ strings('card.card_home.kyc_status.error.description'),
+ [
+ {
+ text: strings('card.card_home.kyc_status.ok_button'),
+ style: 'default',
+ },
+ ],
+ );
+ }
+ }, [
+ isAuthenticated,
+ isBaanxLoginEnabled,
+ needToEnableCard,
+ cardError,
+ kycStatus,
+ isLoading,
+ ]);
+
/**
* Check if the current token supports the spending limit progress bar feature.
* Some tokens (e.g., aUSDC) have different allowance behavior and are unsupported.
diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx
index 6b3f67ffa11..7cac336f11f 100644
--- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx
+++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.test.tsx
@@ -23,9 +23,15 @@ jest.mock('@react-navigation/native', () => {
jest.mock('../../../../../../locales/i18n', () => ({
strings: (key: string) => {
const map: Record = {
- 'card.card_onboarding.title': 'Welcome to MetaMask Card',
- 'card.card_onboarding.description': 'Use your card to spend crypto.',
- 'card.card_onboarding.verify_account_button': 'Verify account',
+ 'card.card_onboarding.title': 'Enable MetaMask Card features',
+ 'card.card_onboarding.description':
+ 'Change your spending token and network by signing in with your Crypto Life email and password.',
+ 'card.card_onboarding.verify_account_button': 'Sign in',
+ 'card.card_onboarding.non_cardholder_title': 'Welcome to MetaMask Card',
+ 'card.card_onboarding.non_cardholder_description':
+ 'MetaMask Card is the free and easy way to spend your crypto, with rich onchain rewards.',
+ 'card.card_onboarding.non_cardholder_verify_account_button':
+ 'Get started',
'card.card': 'Card',
};
return map[key] || key;
@@ -38,14 +44,10 @@ jest.mock('../../../../../util/theme', () => ({
useTheme: () => ({ colors: { background: { default: '#fff' } } }),
}));
-jest.mock('../../hooks/useIsCardholder', () => ({
- useIsCardholder: jest.fn(() => true),
-}));
-
-const createTestStore = () =>
+const createTestStore = (initialState = {}) =>
configureStore({
reducer: {
- card: (state = {}) => state,
+ card: (state = { cardholderAccounts: [], ...initialState }) => state,
},
});
@@ -55,36 +57,114 @@ describe('CardWelcome', () => {
beforeEach(() => {
jest.clearAllMocks();
mockNavigate.mockClear();
- store = createTestStore();
});
- it('renders required UI elements', () => {
- const { getByTestId } = render(
-
-
- ,
- );
-
- expect(getByTestId(CardWelcomeSelectors.CARD_IMAGE)).toBeTruthy();
- expect(
- getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_TITLE_TEXT),
- ).toHaveTextContent(strings('card.card_onboarding.title'));
- expect(
- getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_DESCRIPTION_TEXT),
- ).toHaveTextContent(strings('card.card_onboarding.description'));
- expect(
- getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON),
- ).toBeTruthy();
+ describe('Non-cardholder flow', () => {
+ beforeEach(() => {
+ store = createTestStore({ cardholderAccounts: [] });
+ });
+
+ it('renders required UI elements', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(getByTestId(CardWelcomeSelectors.CARD_IMAGE)).toBeTruthy();
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_TITLE_TEXT),
+ ).toBeTruthy();
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_DESCRIPTION_TEXT),
+ ).toBeTruthy();
+ expect(
+ getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON),
+ ).toBeTruthy();
+ });
+
+ it('displays non-cardholder title when no cardholder accounts exist', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_TITLE_TEXT),
+ ).toHaveTextContent(strings('card.card_onboarding.non_cardholder_title'));
+ });
+
+ it('displays non-cardholder description when no cardholder accounts exist', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_DESCRIPTION_TEXT),
+ ).toHaveTextContent(
+ strings('card.card_onboarding.non_cardholder_description'),
+ );
+ });
+
+ it('navigates to onboarding root when verify account button pressed', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON));
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ONBOARDING.ROOT);
+ });
});
- it('navigates to authentication when verify account button pressed', () => {
- const { getByTestId } = render(
-
-
- ,
- );
- fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON));
- expect(mockNavigate).toHaveBeenCalledTimes(1);
- expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION);
+ describe('Cardholder flow', () => {
+ beforeEach(() => {
+ store = createTestStore({
+ cardholderAccounts: ['0x1234567890abcdef'],
+ });
+ });
+
+ it('displays cardholder title when cardholder accounts exist', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_TITLE_TEXT),
+ ).toHaveTextContent(strings('card.card_onboarding.title'));
+ });
+
+ it('displays cardholder description when cardholder accounts exist', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ expect(
+ getByTestId(CardWelcomeSelectors.WELCOME_TO_CARD_DESCRIPTION_TEXT),
+ ).toHaveTextContent(strings('card.card_onboarding.description'));
+ });
+
+ it('navigates to authentication when verify account button pressed', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ fireEvent.press(getByTestId(CardWelcomeSelectors.VERIFY_ACCOUNT_BUTTON));
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.AUTHENTICATION);
+ });
});
});
diff --git a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
index 789a2ddead4..e351f7c0fe3 100644
--- a/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
+++ b/app/components/UI/Card/Views/CardWelcome/CardWelcome.tsx
@@ -19,13 +19,14 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { CardWelcomeSelectors } from '../../../../../../e2e/selectors/Card/CardWelcome.selectors';
import Routes from '../../../../../constants/navigation/Routes';
import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
-import { useIsCardholder } from '../../hooks/useIsCardholder';
import { CardActions, CardScreens } from '../../util/metrics';
+import { selectHasCardholderAccounts } from '../../../../../core/redux/slices/card';
+import { useSelector } from 'react-redux';
const CardWelcome = () => {
const { trackEvent, createEventBuilder } = useMetrics();
const { navigate } = useNavigation();
- const isCardholder = useIsCardholder();
+ const hasCardholderAccounts = useSelector(selectHasCardholderAccounts);
const theme = useTheme();
const deviceWidth = useWindowDimensions().width;
const styles = createStyles(theme, deviceWidth);
@@ -41,7 +42,7 @@ const CardWelcome = () => {
}, [trackEvent, createEventBuilder]);
const cardWelcomeCopies = useMemo(() => {
- if (isCardholder) {
+ if (hasCardholderAccounts) {
return {
title: strings('card.card_onboarding.title'),
description: strings('card.card_onboarding.description'),
@@ -58,7 +59,7 @@ const CardWelcome = () => {
'card.card_onboarding.non_cardholder_verify_account_button',
),
};
- }, [isCardholder]);
+ }, [hasCardholderAccounts]);
const handleButtonPress = useCallback(() => {
trackEvent(
@@ -69,12 +70,12 @@ const CardWelcome = () => {
.build(),
);
- if (isCardholder) {
+ if (hasCardholderAccounts) {
navigate(Routes.CARD.AUTHENTICATION);
} else {
navigate(Routes.CARD.ONBOARDING.ROOT);
}
- }, [isCardholder, navigate, trackEvent, createEventBuilder]);
+ }, [hasCardholderAccounts, navigate, trackEvent, createEventBuilder]);
return (
diff --git a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.test.tsx b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.test.tsx
index 5025537d98e..170adc8f960 100644
--- a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.test.tsx
@@ -344,6 +344,7 @@ const createTestStore = (initialState = {}) =>
describe('ConfirmPhoneNumber Component', () => {
const mockNavigate = jest.fn();
+ const mockReset = jest.fn();
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
@@ -358,6 +359,7 @@ describe('ConfirmPhoneNumber Component', () => {
mockUseNavigation.mockReturnValue({
navigate: mockNavigate,
+ reset: mockReset,
} as never);
mockUseParams.mockReturnValue({
phoneCountryCode: '1',
@@ -559,7 +561,7 @@ describe('ConfirmPhoneNumber Component', () => {
expect(button.props.disabled).toBe(false);
});
- it('should navigate to VERIFY_IDENTITY when continue button is pressed', async () => {
+ it('navigates to VERIFY_IDENTITY when continue button is pressed', async () => {
const store = createTestStore();
const { getByTestId } = render(
@@ -571,18 +573,22 @@ describe('ConfirmPhoneNumber Component', () => {
fireEvent.changeText(codeFieldInput, '123456');
const button = getByTestId('confirm-phone-number-continue-button');
- fireEvent.press(button);
+
+ await act(async () => {
+ fireEvent.press(button);
+ });
await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
- );
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.ONBOARDING.VERIFY_IDENTITY }],
+ });
});
});
});
describe('Auto-submit Functionality', () => {
- it('should auto-submit when 6 digits are entered', async () => {
+ it('auto-submits when 6 digits are entered', async () => {
const store = createTestStore();
const { getByTestId } = render(
@@ -591,16 +597,20 @@ describe('ConfirmPhoneNumber Component', () => {
);
const codeFieldInput = getByTestId('confirm-phone-number-code-field');
- fireEvent.changeText(codeFieldInput, '123456');
+
+ await act(async () => {
+ fireEvent.changeText(codeFieldInput, '123456');
+ });
await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.CARD.ONBOARDING.VERIFY_IDENTITY,
- );
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.ONBOARDING.VERIFY_IDENTITY }],
+ });
});
});
- it('should not auto-submit when less than 6 digits are entered', async () => {
+ it('does not auto-submit when less than 6 digits are entered', async () => {
const store = createTestStore();
const { getByTestId } = render(
@@ -615,10 +625,10 @@ describe('ConfirmPhoneNumber Component', () => {
act(() => {
jest.runOnlyPendingTimers();
});
- expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockReset).not.toHaveBeenCalled();
});
- it('should not auto-submit the same code twice', async () => {
+ it('does not auto-submit the same code twice', async () => {
const store = createTestStore();
const { getByTestId } = render(
@@ -629,21 +639,26 @@ describe('ConfirmPhoneNumber Component', () => {
const codeFieldInput = getByTestId('confirm-phone-number-code-field');
// First submission
- fireEvent.changeText(codeFieldInput, '123456');
+ await act(async () => {
+ fireEvent.changeText(codeFieldInput, '123456');
+ });
+
await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockReset).toHaveBeenCalledTimes(1);
});
// Clear the mock to reset call count
- mockNavigate.mockClear();
+ mockReset.mockClear();
// Clear and enter different code
- fireEvent.changeText(codeFieldInput, '');
- fireEvent.changeText(codeFieldInput, '654321');
+ await act(async () => {
+ fireEvent.changeText(codeFieldInput, '');
+ fireEvent.changeText(codeFieldInput, '654321');
+ });
- // Should navigate again with new code
+ // Should reset navigation again with new code
await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockReset).toHaveBeenCalledTimes(1);
});
});
});
@@ -1069,9 +1084,10 @@ describe('ConfirmPhoneNumber Component', () => {
});
await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- 'CardOnboardingVerifyIdentity',
- );
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.ONBOARDING.VERIFY_IDENTITY }],
+ });
});
});
diff --git a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
index 8342060a6a1..c7f7be783b2 100644
--- a/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
+++ b/app/components/UI/Card/components/Onboarding/ConfirmPhoneNumber.tsx
@@ -136,7 +136,10 @@ const ConfirmPhoneNumber = () => {
});
if (user) {
setUser(user);
- navigation.navigate(Routes.CARD.ONBOARDING.VERIFY_IDENTITY);
+ navigation.reset({
+ index: 0,
+ routes: [{ name: Routes.CARD.ONBOARDING.VERIFY_IDENTITY }],
+ });
}
} catch (error) {
if (
diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
index a4f539fa62d..fe4cc540a69 100644
--- a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx
@@ -298,6 +298,7 @@ jest.mock('../../../../../util/Logger');
// Mock Routes
jest.mock('../../../../../constants/navigation/Routes', () => ({
CARD: {
+ VERIFYING_REGISTRATION: 'VerifyingRegistration',
ONBOARDING: {
COMPLETE: 'CardOnboardingComplete',
SIGN_UP: 'CardOnboardingSignUp',
@@ -392,6 +393,7 @@ const createTestStore = (initialState = {}) =>
// Mock functions
const mockNavigate = jest.fn();
+const mockReset = jest.fn();
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
@@ -418,6 +420,7 @@ describe('MailingAddress Component', () => {
// Mock navigation
mockUseNavigation.mockReturnValue({
navigate: mockNavigate,
+ reset: mockReset,
} as unknown as ReturnType);
// Mock useRegisterMailingAddress
@@ -513,6 +516,7 @@ describe('MailingAddress Component', () => {
id: 'user-id',
email: 'test@example.com',
},
+ fetchUserData: jest.fn(),
setUser: mockSetUser,
logoutFromProvider: jest.fn(),
});
@@ -1330,9 +1334,16 @@ describe('MailingAddress Component', () => {
fireEvent.press(button);
});
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith('CardOnboardingComplete');
- });
+ // Wait for token storage and Redux updates before navigation
+ await waitFor(
+ () => {
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: 'VerifyingRegistration' }],
+ });
+ },
+ { timeout: 3000 },
+ );
});
it('navigates to sign up when Onboarding ID not found error occurs', async () => {
@@ -1411,10 +1422,10 @@ describe('MailingAddress Component', () => {
});
describe('Input Change Handler Error Resets', () => {
- let mockReset: jest.Mock;
+ let mockResetHandler: jest.Mock;
beforeEach(() => {
- mockReset = jest.fn();
+ mockResetHandler = jest.fn();
mockUseRegisterMailingAddress.mockReturnValue({
registerAddress: jest.fn(),
isLoading: false,
@@ -1422,7 +1433,7 @@ describe('MailingAddress Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- reset: mockReset,
+ reset: mockResetHandler,
});
});
@@ -1436,7 +1447,7 @@ describe('MailingAddress Component', () => {
const input = getByTestId('address-line-1-input');
fireEvent.changeText(input, '123 Main St');
- expect(mockReset).toHaveBeenCalled();
+ expect(mockResetHandler).toHaveBeenCalled();
});
it('calls reset when address line 2 changes', () => {
@@ -1449,7 +1460,7 @@ describe('MailingAddress Component', () => {
const input = getByTestId('address-line-2-input');
fireEvent.changeText(input, 'Apt 4B');
- expect(mockReset).toHaveBeenCalled();
+ expect(mockResetHandler).toHaveBeenCalled();
});
it('calls reset when city changes', () => {
@@ -1462,7 +1473,7 @@ describe('MailingAddress Component', () => {
const input = getByTestId('city-input');
fireEvent.changeText(input, 'San Francisco');
- expect(mockReset).toHaveBeenCalled();
+ expect(mockResetHandler).toHaveBeenCalled();
});
it('calls reset when state changes', () => {
@@ -1475,7 +1486,7 @@ describe('MailingAddress Component', () => {
const input = getByTestId('state-select');
fireEvent.press(input);
- expect(mockReset).toHaveBeenCalled();
+ expect(mockResetHandler).toHaveBeenCalled();
});
it('calls reset when zip code changes', () => {
@@ -1488,7 +1499,7 @@ describe('MailingAddress Component', () => {
const input = getByTestId('zip-code-input');
fireEvent.changeText(input, '94102');
- expect(mockReset).toHaveBeenCalled();
+ expect(mockResetHandler).toHaveBeenCalled();
});
});
@@ -1728,9 +1739,16 @@ describe('MailingAddress Component', () => {
expect(mockCreateOnboardingConsent).not.toHaveBeenCalled();
expect(mockLinkUserToConsent).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith('CardOnboardingComplete');
- });
+ // Wait for token storage and Redux updates before navigation
+ await waitFor(
+ () => {
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: 'VerifyingRegistration' }],
+ });
+ },
+ { timeout: 3000 },
+ );
});
it('uses existing consent set ID from Redux when available', async () => {
diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
index 26840eb7afa..7ed112b52d1 100644
--- a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
+++ b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx
@@ -211,8 +211,12 @@ const MailingAddress = () => {
dispatch(setConsentSetId(null));
}
- // Registration complete
- navigation.navigate(Routes.CARD.ONBOARDING.COMPLETE);
+ // Reset the navigation stack to the verifying registration screen
+ navigation.reset({
+ index: 0,
+ routes: [{ name: Routes.CARD.VERIFYING_REGISTRATION }],
+ });
+ return;
}
// Something is wrong. We need to display the registerError or restart the flow
diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
index 6bf5191702e..a083ed89f83 100644
--- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx
@@ -391,6 +391,7 @@ const createTestStore = (initialState = {}) =>
// Mock functions
const mockNavigate = jest.fn();
+const mockReset = jest.fn();
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
@@ -416,6 +417,7 @@ describe('PhysicalAddress Component', () => {
// Mock navigation
mockUseNavigation.mockReturnValue({
navigate: mockNavigate,
+ reset: mockReset,
} as unknown as ReturnType);
// Mock useRegisterPhysicalAddress
@@ -511,6 +513,7 @@ describe('PhysicalAddress Component', () => {
id: 'user-id',
email: 'test@example.com',
},
+ fetchUserData: jest.fn(),
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
});
@@ -1047,11 +1050,16 @@ describe('PhysicalAddress Component', () => {
);
});
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.CARD.ONBOARDING.COMPLETE,
- );
- });
+ // Wait for token storage and Redux updates before navigation
+ await waitFor(
+ () => {
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.VERIFYING_REGISTRATION }],
+ });
+ },
+ { timeout: 3000 },
+ );
});
});
@@ -1303,11 +1311,16 @@ describe('PhysicalAddress Component', () => {
expect(mockCreateOnboardingConsent).not.toHaveBeenCalled();
expect(mockLinkUserToConsent).not.toHaveBeenCalled();
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.CARD.ONBOARDING.COMPLETE,
- );
- });
+ // Wait for token storage and Redux updates before navigation
+ await waitFor(
+ () => {
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.VERIFYING_REGISTRATION }],
+ });
+ },
+ { timeout: 3000 },
+ );
});
it('uses existing consent set ID from Redux when available', async () => {
@@ -1418,11 +1431,16 @@ describe('PhysicalAddress Component', () => {
);
});
- await waitFor(() => {
- expect(mockNavigate).toHaveBeenCalledWith(
- Routes.CARD.ONBOARDING.COMPLETE,
- );
- });
+ // Wait for token storage and Redux updates before navigation
+ await waitFor(
+ () => {
+ expect(mockReset).toHaveBeenCalledWith({
+ index: 0,
+ routes: [{ name: Routes.CARD.VERIFYING_REGISTRATION }],
+ });
+ },
+ { timeout: 3000 },
+ );
});
it('clears consent set ID from Redux after linking consent', async () => {
diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
index fd6417cca97..237e9ec941b 100644
--- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
+++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx
@@ -520,8 +520,11 @@ const PhysicalAddress = () => {
dispatch(setConsentSetId(null));
}
- // Navigate to completion screen
- navigation.navigate(Routes.CARD.ONBOARDING.COMPLETE);
+ // Reset the navigation stack to the verifying registration screen
+ navigation.reset({
+ index: 0,
+ routes: [{ name: Routes.CARD.VERIFYING_REGISTRATION }],
+ });
return;
}
diff --git a/app/components/UI/Card/components/Onboarding/ValidatingKYC.test.tsx b/app/components/UI/Card/components/Onboarding/ValidatingKYC.test.tsx
index b308d459f80..a44b9fd96d0 100644
--- a/app/components/UI/Card/components/Onboarding/ValidatingKYC.test.tsx
+++ b/app/components/UI/Card/components/Onboarding/ValidatingKYC.test.tsx
@@ -132,10 +132,15 @@ import useUserRegistrationStatus from '../../hooks/useUserRegistrationStatus';
describe('ValidatingKYC Component', () => {
let mockNavigate: jest.Mock;
let mockUseUserRegistrationStatus: jest.Mock;
+ let mockStartPolling: jest.Mock;
+ let mockStopPolling: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockNavigate = jest.fn();
+ mockStartPolling = jest.fn();
+ mockStopPolling = jest.fn();
+
(useNavigation as jest.Mock).mockReturnValue({
navigate: mockNavigate,
});
@@ -152,7 +157,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
(useUserRegistrationStatus as jest.Mock).mockImplementation(
mockUseUserRegistrationStatus,
@@ -203,7 +209,8 @@ describe('ValidatingKYC Component', () => {
isError: true,
error: 'Verification failed',
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
const { getByTestId } = render();
@@ -223,7 +230,8 @@ describe('ValidatingKYC Component', () => {
isError: true,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
const { getByTestId } = render();
@@ -240,7 +248,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
const { getByTestId } = render();
@@ -258,7 +267,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
const { getByTestId } = render();
@@ -278,7 +288,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
render();
@@ -298,7 +309,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
render();
@@ -316,7 +328,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
render();
@@ -350,7 +363,8 @@ describe('ValidatingKYC Component', () => {
isError: false,
error: null,
clearError: jest.fn(),
- fetchRegistrationStatus: jest.fn(),
+ startPolling: mockStartPolling,
+ stopPolling: mockStopPolling,
});
render();
@@ -363,6 +377,39 @@ describe('ValidatingKYC Component', () => {
});
});
+ describe('Polling Lifecycle', () => {
+ it('starts polling when component mounts', () => {
+ render();
+
+ expect(mockStartPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling when component unmounts', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ expect(mockStopPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls startPolling before stopPolling', () => {
+ const callOrder: string[] = [];
+
+ mockStartPolling.mockImplementation(() => {
+ callOrder.push('start');
+ });
+
+ mockStopPolling.mockImplementation(() => {
+ callOrder.push('stop');
+ });
+
+ const { unmount } = render();
+ unmount();
+
+ expect(callOrder).toEqual(['start', 'stop']);
+ });
+ });
+
describe('Component Integration', () => {
it('passes correct props to OnboardingStep', () => {
const { getByTestId } = render();
diff --git a/app/components/UI/Card/components/Onboarding/ValidatingKYC.tsx b/app/components/UI/Card/components/Onboarding/ValidatingKYC.tsx
index d5b740cc5d2..2c3cd4b434a 100644
--- a/app/components/UI/Card/components/Onboarding/ValidatingKYC.tsx
+++ b/app/components/UI/Card/components/Onboarding/ValidatingKYC.tsx
@@ -12,7 +12,8 @@ const ValidatingKYC = () => {
const navigation = useNavigation();
const { trackEvent, createEventBuilder } = useMetrics();
- const { verificationState } = useUserRegistrationStatus();
+ const { verificationState, startPolling, stopPolling } =
+ useUserRegistrationStatus();
useEffect(() => {
trackEvent(
@@ -24,6 +25,13 @@ const ValidatingKYC = () => {
);
}, [trackEvent, createEventBuilder]);
+ useEffect(() => {
+ startPolling();
+ return () => {
+ stopPolling();
+ };
+ }, [startPolling, stopPolling]);
+
useEffect(() => {
if (verificationState === 'VERIFIED') {
navigation.navigate(Routes.CARD.ONBOARDING.PERSONAL_DETAILS);
diff --git a/app/components/UI/Card/components/Onboarding/VerifyingRegistration.test.tsx b/app/components/UI/Card/components/Onboarding/VerifyingRegistration.test.tsx
new file mode 100644
index 00000000000..ee180f920ba
--- /dev/null
+++ b/app/components/UI/Card/components/Onboarding/VerifyingRegistration.test.tsx
@@ -0,0 +1,920 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import React from 'react';
+import { render, waitFor, act, screen } from '@testing-library/react-native';
+import { StackActions, useNavigation } from '@react-navigation/native';
+import VerifyingRegistration from './VerifyingRegistration';
+import Routes from '../../../../../constants/navigation/Routes';
+import Logger from '../../../../../util/Logger';
+import { CARD_SUPPORT_EMAIL } from '../../constants';
+
+// Mock navigation
+const mockNavigationDispatch = jest.fn();
+const mockSetOptions = jest.fn();
+const mockStackReplace = jest.fn((routeName: string) => ({
+ type: 'REPLACE',
+ routeName,
+}));
+const mockStackPop = jest.fn(() => ({
+ type: 'POP',
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(),
+ StackActions: {
+ replace: jest.fn((routeName: string) => ({
+ type: 'REPLACE',
+ routeName,
+ })),
+ pop: jest.fn(() => ({
+ type: 'POP',
+ })),
+ },
+}));
+
+// Mock SDK
+const mockGetUserDetails = jest.fn();
+jest.mock('../../sdk', () => ({
+ useCardSDK: jest.fn(),
+}));
+
+// Mock Logger
+jest.mock('../../../../../util/Logger', () => ({
+ log: jest.fn(),
+}));
+
+// Mock useMetrics
+const mockTrackEvent = jest.fn();
+const mockCreateEventBuilder = jest.fn();
+
+jest.mock('../../../../hooks/useMetrics', () => ({
+ useMetrics: jest.fn(),
+ MetaMetricsEvents: {
+ CARD_VIEWED: 'card_viewed',
+ CARD_BUTTON_CLICKED: 'card_button_clicked',
+ },
+}));
+
+// Mock Redux
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ useDispatch: jest.fn(),
+}));
+
+// Mock constants
+jest.mock('../../constants', () => ({
+ CARD_SUPPORT_EMAIL: 'metamask@cl-cards.com',
+}));
+
+// Mock OnboardingStep component
+jest.mock('./OnboardingStep', () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const React = jest.requireActual('react');
+ const { View, Text } = jest.requireActual('react-native');
+
+ return ({
+ title,
+ description,
+ formFields,
+ actions,
+ }: {
+ title: string;
+ description: string;
+ formFields: React.ReactNode;
+ actions: React.ReactNode;
+ }) =>
+ React.createElement(
+ View,
+ { testID: 'onboarding-step' },
+ React.createElement(Text, { testID: 'onboarding-step-title' }, title),
+ React.createElement(
+ Text,
+ { testID: 'onboarding-step-description' },
+ description,
+ ),
+ React.createElement(
+ View,
+ { testID: 'onboarding-step-form-fields' },
+ formFields,
+ ),
+ React.createElement(View, { testID: 'onboarding-step-actions' }, actions),
+ );
+});
+
+// Mock design system components
+jest.mock('@metamask/design-system-react-native', () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const React = jest.requireActual('react');
+ const { View, Text: RNText } = jest.requireActual('react-native');
+
+ const Box = ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) =>
+ React.createElement(View, props, children);
+
+ const Text = ({
+ children,
+ ...props
+ }: React.PropsWithChildren>) =>
+ React.createElement(RNText, props, children);
+
+ return {
+ Box,
+ Text,
+ TextVariant: {
+ BodyMd: 'BodyMd',
+ },
+ FontWeight: {
+ Bold: 'Bold',
+ },
+ };
+});
+
+// Mock ButtonIcon
+jest.mock('../../../../../component-library/components/Buttons/ButtonIcon');
+
+// Mock Button
+jest.mock('../../../../../component-library/components/Buttons/Button', () => {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const React = jest.requireActual('react');
+ const { TouchableOpacity, Text } = jest.requireActual('react-native');
+
+ return {
+ __esModule: true,
+ default: ({
+ label,
+ onPress,
+ testID,
+ ...props
+ }: {
+ label: string;
+ onPress: () => void;
+ testID: string;
+ }) =>
+ React.createElement(
+ TouchableOpacity,
+ { testID, onPress, ...props },
+ React.createElement(Text, {}, label),
+ ),
+ ButtonSize: {
+ Lg: 'Lg',
+ },
+ ButtonVariants: {
+ Primary: 'Primary',
+ },
+ ButtonWidthTypes: {
+ Full: 'Full',
+ },
+ };
+});
+
+// Mock i18n
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string, options?: Record) => {
+ const translations: Record = {
+ 'card.card_onboarding.verifying_registration.title':
+ 'Verifying your identity',
+ 'card.card_onboarding.verifying_registration.description':
+ 'Crypto Life is attempting to verify you. This will take at most 30 seconds.',
+ 'card.card_onboarding.verifying_registration.verified_title': 'Approved!',
+ 'card.card_onboarding.verifying_registration.verified_description':
+ 'Your KYC was approved. You can now use MetaMask Card.',
+ 'card.card_onboarding.verifying_registration.continue_button': 'Continue',
+ 'card.card_onboarding.verifying_registration.timeout_title':
+ 'Verification in progress',
+ 'card.card_onboarding.verifying_registration.timeout_description':
+ 'Your verification will take up to 12 hours. Please come back to the Card section in that time.',
+ 'card.card_onboarding.verifying_registration.rejected_title':
+ 'Verification incomplete',
+ 'card.card_onboarding.verifying_registration.rejected_description':
+ 'We need a bit more information to complete your verification.',
+ 'card.card_onboarding.verifying_registration.rejected_message': `Please reach out to our support team and we'll help you complete the process. ${options?.email || ''}`,
+ 'card.card_onboarding.verifying_registration.server_error_title_main':
+ 'Something went wrong',
+ 'card.card_onboarding.verifying_registration.server_error_title':
+ "We're experiencing server issues",
+ 'card.card_onboarding.verifying_registration.server_error_message': `We're unable to complete your verification at the moment. Please try again later or contact support at ${options?.email || ''} for assistance.`,
+ };
+ return translations[key] || key;
+ }),
+}));
+
+// Mock header style
+jest.mock('../../routes', () => ({
+ headerStyle: {
+ icon: {},
+ },
+}));
+
+// Helper function to create mock user data
+const createMockUserResponse = (overrides: Record = {}) => ({
+ id: 'user-123',
+ email: 'test@example.com',
+ verificationState: 'PENDING',
+ firstName: 'John',
+ lastName: 'Doe',
+ dateOfBirth: '1990-01-01',
+ phoneNumber: '1234567890',
+ phoneCountryCode: '+1',
+ addressLine1: '123 Main St',
+ addressLine2: null,
+ city: 'New York',
+ zip: '10001',
+ usState: null,
+ ssn: null,
+ countryOfResidence: 'US',
+ countryOfNationality: 'US',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ ...overrides,
+});
+
+describe('VerifyingRegistration Component', () => {
+ const { useMetrics } = jest.requireMock('../../../../hooks/useMetrics');
+ const { useCardSDK } = jest.requireMock('../../sdk');
+ const { useDispatch } = jest.requireMock('react-redux');
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ (useNavigation as jest.Mock).mockReturnValue({
+ dispatch: mockNavigationDispatch,
+ setOptions: mockSetOptions,
+ });
+
+ (StackActions.replace as jest.Mock).mockImplementation(mockStackReplace);
+ (StackActions.pop as jest.Mock).mockImplementation(mockStackPop);
+
+ mockCreateEventBuilder.mockReturnValue({
+ addProperties: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnValue({}),
+ });
+
+ useMetrics.mockReturnValue({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ });
+
+ useCardSDK.mockReturnValue({
+ sdk: {
+ getUserDetails: mockGetUserDetails,
+ },
+ setUser: jest.fn(),
+ });
+
+ useDispatch.mockReturnValue(mockDispatch);
+
+ mockGetUserDetails.mockResolvedValue(createMockUserResponse());
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ jest.runOnlyPendingTimers();
+ });
+ jest.useRealTimers();
+ });
+
+ describe('Component Rendering', () => {
+ it('renders the VerifyingRegistration component', () => {
+ render();
+
+ expect(screen.getByTestId('onboarding-step')).toBeTruthy();
+ });
+
+ it('renders polling state title', () => {
+ render();
+
+ const title = screen.getByTestId('onboarding-step-title');
+
+ expect(title.props.children).toBe('Verifying your identity');
+ });
+
+ it('renders polling state description', () => {
+ render();
+
+ const description = screen.getByTestId('onboarding-step-description');
+
+ expect(description.props.children).toBe(
+ 'Crypto Life is attempting to verify you. This will take at most 30 seconds.',
+ );
+ });
+
+ it('renders form fields during polling', () => {
+ render();
+
+ const formFields = screen.getByTestId('onboarding-step-form-fields');
+
+ expect(formFields).toBeTruthy();
+ });
+ });
+
+ describe('Polling Behavior', () => {
+ it('starts polling immediately on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('polls getUserDetails every 3 seconds', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(2);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ it('stops polling after 30 seconds timeout', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalled();
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ const title = screen.getByTestId('onboarding-step-title');
+ expect(title.props.children).toBe('Verification in progress');
+ });
+ });
+
+ it('cleans up polling on unmount', async () => {
+ const { unmount } = render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ // No additional calls after unmount
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('VERIFIED State', () => {
+ it('stops polling when verification is complete', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays verified title when verification completes', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ const title = screen.getByTestId('onboarding-step-title');
+ expect(title.props.children).toBe('Approved!');
+ });
+ });
+
+ it('displays verified description when verification completes', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ const description = screen.getByTestId('onboarding-step-description');
+ expect(description.props.children).toBe(
+ 'Your KYC was approved. You can now use MetaMask Card.',
+ );
+ });
+ });
+
+ it('displays continue button when verified', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ const button = screen.getByTestId(
+ 'verifying-registration-continue-button',
+ );
+ expect(button).toBeTruthy();
+ });
+ });
+
+ it('resets onboarding state on mount', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ });
+
+ it('navigates to Card Home when continue button is pressed', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ const button = await screen.findByTestId(
+ 'verifying-registration-continue-button',
+ );
+
+ await act(async () => {
+ await button.props.onPress();
+ });
+
+ await waitFor(() => {
+ expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME);
+ });
+ });
+
+ it('dispatches replace action when continue button is pressed', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+
+ render();
+
+ const button = await screen.findByTestId(
+ 'verifying-registration-continue-button',
+ );
+
+ await act(async () => {
+ await button.props.onPress();
+ });
+
+ await waitFor(() => {
+ expect(mockNavigationDispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ routeName: Routes.CARD.HOME }),
+ );
+ });
+ });
+ });
+
+ describe('REJECTED State', () => {
+ it('displays rejected title when verification is rejected', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ const title = screen.getByTestId('onboarding-step-title');
+
+ expect(title.props.children).toBe('Verification incomplete');
+ });
+ });
+
+ it('displays rejected description when verification is rejected', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ const description = screen.getByTestId('onboarding-step-description');
+
+ expect(description.props.children).toBe(
+ 'We need a bit more information to complete your verification.',
+ );
+ });
+ });
+
+ it('displays support contact message when verification is rejected', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.rejected_message',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ });
+ });
+
+ it('stops polling when verification is rejected', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Timeout State', () => {
+ it('displays timeout title after 30 seconds', async () => {
+ render();
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ const title = screen.getByTestId('onboarding-step-title');
+
+ expect(title.props.children).toBe('Verification in progress');
+ });
+ });
+
+ it('displays timeout description after 30 seconds', async () => {
+ render();
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ const description = screen.getByTestId('onboarding-step-description');
+
+ expect(description.props.children).toBe(
+ 'Your verification will take up to 12 hours. Please come back to the Card section in that time.',
+ );
+ });
+ });
+
+ it('stops polling after timeout', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalled();
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ const callCountAfterTimeout = mockGetUserDetails.mock.calls.length;
+
+ await act(async () => {
+ jest.advanceTimersByTime(10000);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(callCountAfterTimeout);
+ });
+ });
+
+ describe('Error State', () => {
+ it('displays error title when API fails', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+
+ render();
+
+ await waitFor(() => {
+ const title = screen.getByTestId('onboarding-step-title');
+
+ expect(title.props.children).toBe('Something went wrong');
+ });
+ });
+
+ it('displays server error message when API fails', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.server_error_message',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ });
+ });
+
+ it('logs error when getUserDetails fails', async () => {
+ const error = new Error('Network error');
+ mockGetUserDetails.mockRejectedValueOnce(error);
+
+ render();
+
+ await waitFor(() => {
+ expect(Logger.log).toHaveBeenCalledWith(
+ 'VerifyingRegistration: Error fetching user details',
+ error,
+ );
+ });
+ });
+
+ it('stops polling when API fails', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Navigation Integration', () => {
+ it('calls setOptions to configure header', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockSetOptions).toHaveBeenCalled();
+ });
+ });
+
+ it('updates header when step changes to rejected', async () => {
+ mockGetUserDetails.mockResolvedValueOnce({
+ verificationState: 'REJECTED',
+ id: 'user-123',
+ email: 'test@example.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ dateOfBirth: '1990-01-01',
+ phoneNumber: '1234567890',
+ phoneCountryCode: '+1',
+ addressLine1: '123 Main St',
+ addressLine2: null,
+ city: 'New York',
+ zip: '10001',
+ usState: null,
+ ssn: null,
+ countryOfResidence: 'US',
+ countryOfNationality: 'US',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(mockSetOptions).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Metrics Tracking', () => {
+ it('tracks screen view on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockTrackEvent).toHaveBeenCalled();
+ expect(mockCreateEventBuilder).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('State Reset', () => {
+ it('resets onboarding state immediately on mount', () => {
+ render();
+
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ it('dispatches resetOnboardingState action on mount', () => {
+ render();
+
+ expect(mockDispatch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('i18n Integration', () => {
+ it('uses correct translation key for polling title', () => {
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.title',
+ );
+ });
+
+ it('uses correct translation key for polling description', () => {
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.description',
+ );
+ });
+
+ it('uses correct translation key for verified title', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.verified_title',
+ );
+ });
+ });
+
+ it('uses correct translation key for verified description', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.verified_description',
+ );
+ });
+ });
+
+ it('uses correct translation key for continue button', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'VERIFIED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.continue_button',
+ );
+ });
+ });
+
+ it('uses correct translation key for timeout title', async () => {
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.timeout_title',
+ );
+ });
+ });
+
+ it('uses correct translation key for timeout description with email', async () => {
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await act(async () => {
+ jest.advanceTimersByTime(30000);
+ });
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.timeout_description',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ });
+ });
+
+ it('uses correct translation key for rejected title', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.rejected_title',
+ );
+ });
+ });
+
+ it('uses correct translation key for rejected description', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.rejected_description',
+ );
+ });
+ });
+
+ it('uses correct translation key for rejected message with email', async () => {
+ mockGetUserDetails.mockResolvedValueOnce(
+ createMockUserResponse({ verificationState: 'REJECTED' }),
+ );
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.rejected_message',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ });
+ });
+
+ it('uses correct translation key for error title', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.server_error_title_main',
+ );
+ });
+ });
+
+ it('uses correct translation key for server error title', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.server_error_title',
+ );
+ });
+ });
+
+ it('uses correct translation key for server error message with email', async () => {
+ mockGetUserDetails.mockRejectedValueOnce(new Error('Network error'));
+ const { strings } = jest.requireMock('../../../../../../locales/i18n');
+
+ render();
+
+ await waitFor(() => {
+ expect(strings).toHaveBeenCalledWith(
+ 'card.card_onboarding.verifying_registration.server_error_message',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Card/components/Onboarding/VerifyingRegistration.tsx b/app/components/UI/Card/components/Onboarding/VerifyingRegistration.tsx
new file mode 100644
index 00000000000..fdd52453d19
--- /dev/null
+++ b/app/components/UI/Card/components/Onboarding/VerifyingRegistration.tsx
@@ -0,0 +1,366 @@
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { StackActions, useNavigation } from '@react-navigation/native';
+import OnboardingStep from './OnboardingStep';
+import { strings } from '../../../../../../locales/i18n';
+import Routes from '../../../../../constants/navigation/Routes';
+import { ActivityIndicator, View } from 'react-native';
+import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics';
+import { CardActions, CardScreens } from '../../util/metrics';
+import { useCardSDK } from '../../sdk';
+import Logger from '../../../../../util/Logger';
+import {
+ Box,
+ Text,
+ TextVariant,
+ FontWeight,
+} from '@metamask/design-system-react-native';
+import { CARD_SUPPORT_EMAIL } from '../../constants';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+import ButtonIcon, {
+ ButtonIconSizes,
+} from '../../../../../component-library/components/Buttons/ButtonIcon';
+import { headerStyle } from '../../routes';
+import { useDispatch } from 'react-redux';
+import { resetOnboardingState } from '../../../../../core/redux/slices/card';
+import Button, {
+ ButtonSize,
+ ButtonVariants,
+ ButtonWidthTypes,
+} from '../../../../../component-library/components/Buttons/Button';
+
+const POLLING_INTERVAL = 3000; // 3 seconds
+const TIMEOUT_DURATION = 30000; // 30 seconds
+
+type VerificationStep =
+ | 'polling'
+ | 'verified'
+ | 'timeout'
+ | 'rejected'
+ | 'error';
+
+const VerifyingRegistration = () => {
+ const navigation = useNavigation();
+ const dispatch = useDispatch();
+ const { trackEvent, createEventBuilder } = useMetrics();
+ const { sdk, setUser } = useCardSDK();
+ const [step, setStep] = useState('polling');
+ const [isHandlingContinue, setIsHandlingContinue] = useState(false);
+ const intervalRef = useRef(null);
+ const timeoutRef = useRef(null);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties({
+ screen: CardScreens.VERIFYING_REGISTRATION,
+ })
+ .build(),
+ );
+ }, [trackEvent, createEventBuilder]);
+
+ const handleClose = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ action: CardActions.VERIFYING_REGISTRATION_CLOSE_BUTTON,
+ })
+ .build(),
+ );
+
+ navigation.dispatch(StackActions.pop());
+ }, [navigation, trackEvent, createEventBuilder]);
+
+ const handleContinue = useCallback(async () => {
+ setIsHandlingContinue(true);
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ action: CardActions.VERIFYING_REGISTRATION_CONTINUE_BUTTON,
+ })
+ .build(),
+ );
+
+ try {
+ navigation.dispatch(StackActions.replace(Routes.CARD.HOME));
+ } catch (error) {
+ Logger.log('VerifyingRegistration::handleContinue error', error);
+ } finally {
+ setIsHandlingContinue(false);
+ }
+ }, [navigation, trackEvent, createEventBuilder]);
+
+ const fetchUserDetails = useCallback(async () => {
+ if (!sdk) {
+ Logger.log('VerifyingRegistration: SDK not available');
+ return;
+ }
+
+ try {
+ const response = await sdk.getUserDetails();
+
+ if (!mountedRef.current) {
+ return;
+ }
+
+ setUser(response);
+
+ if (response.verificationState === 'VERIFIED') {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ if (mountedRef.current) {
+ setStep('verified');
+ }
+ return;
+ }
+
+ if (response.verificationState === 'REJECTED') {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ if (mountedRef.current) {
+ setStep('rejected');
+ }
+ return;
+ }
+ } catch (err) {
+ Logger.log('VerifyingRegistration: Error fetching user details', err);
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+
+ if (mountedRef.current) {
+ setStep('error');
+ }
+ }
+ }, [sdk, setUser]);
+
+ const startPolling = useCallback(() => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+
+ fetchUserDetails();
+
+ intervalRef.current = setInterval(() => {
+ fetchUserDetails();
+ }, POLLING_INTERVAL);
+
+ timeoutRef.current = setTimeout(() => {
+ if (mountedRef.current) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+
+ setStep('timeout');
+ }
+ }, TIMEOUT_DURATION);
+ }, [fetchUserDetails]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ dispatch(resetOnboardingState());
+ startPolling();
+
+ return () => {
+ mountedRef.current = false;
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [startPolling, dispatch]);
+
+ const headerRight = useMemo(
+ () =>
+ step !== 'polling' && step !== 'verified'
+ ? () => (
+
+ )
+ : () => ,
+ [step, handleClose],
+ );
+
+ useEffect(() => {
+ navigation.setOptions({
+ // eslint-disable-next-line react/no-unstable-nested-components
+ headerLeft: () => ,
+ headerRight,
+ });
+ }, [headerRight, navigation]);
+
+ const renderFormFields = () => {
+ switch (step) {
+ case 'verified':
+ return null; // Description will show the success message
+
+ case 'error':
+ return (
+
+
+ {strings(
+ 'card.card_onboarding.verifying_registration.server_error_title',
+ )}
+
+
+ {strings(
+ 'card.card_onboarding.verifying_registration.server_error_message',
+ { email: CARD_SUPPORT_EMAIL },
+ )}
+
+
+ );
+
+ case 'rejected':
+ return (
+
+
+ {strings(
+ 'card.card_onboarding.verifying_registration.rejected_message',
+ { email: CARD_SUPPORT_EMAIL },
+ )}
+
+
+ );
+
+ case 'polling':
+ return (
+
+
+
+ );
+
+ case 'timeout':
+ return null;
+
+ default:
+ return null;
+ }
+ };
+
+ const renderActions = () => {
+ if (step === 'verified') {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ const getTitle = () => {
+ switch (step) {
+ case 'verified':
+ return strings(
+ 'card.card_onboarding.verifying_registration.verified_title',
+ );
+ case 'error':
+ return strings(
+ 'card.card_onboarding.verifying_registration.server_error_title_main',
+ );
+ case 'rejected':
+ return strings(
+ 'card.card_onboarding.verifying_registration.rejected_title',
+ );
+ case 'timeout':
+ return strings(
+ 'card.card_onboarding.verifying_registration.timeout_title',
+ );
+ case 'polling':
+ default:
+ return strings('card.card_onboarding.verifying_registration.title');
+ }
+ };
+
+ const getDescription = () => {
+ switch (step) {
+ case 'verified':
+ return strings(
+ 'card.card_onboarding.verifying_registration.verified_description',
+ );
+ case 'error':
+ return '';
+ case 'rejected':
+ return strings(
+ 'card.card_onboarding.verifying_registration.rejected_description',
+ );
+ case 'timeout':
+ return strings(
+ 'card.card_onboarding.verifying_registration.timeout_description',
+ { email: CARD_SUPPORT_EMAIL },
+ );
+ case 'polling':
+ default:
+ return strings(
+ 'card.card_onboarding.verifying_registration.description',
+ );
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default VerifyingRegistration;
diff --git a/app/components/UI/Card/constants.ts b/app/components/UI/Card/constants.ts
index a4100734c51..7f4047062f5 100644
--- a/app/components/UI/Card/constants.ts
+++ b/app/components/UI/Card/constants.ts
@@ -17,3 +17,4 @@ export const SUPPORTED_ASSET_NETWORKS = ['linea', 'linea-us', 'solana'];
* Format: Token symbols in uppercase
*/
export const SPENDING_LIMIT_UNSUPPORTED_TOKENS = ['AUSDC'];
+export const CARD_SUPPORT_EMAIL = 'metamask@cl-cards.com';
diff --git a/app/components/UI/Card/hooks/useCardDelegation.test.ts b/app/components/UI/Card/hooks/useCardDelegation.test.ts
index 954d16dac84..fff41a4a8f5 100644
--- a/app/components/UI/Card/hooks/useCardDelegation.test.ts
+++ b/app/components/UI/Card/hooks/useCardDelegation.test.ts
@@ -133,11 +133,8 @@ describe('useCardDelegation', () => {
};
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK as unknown as CardSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
// Setup metrics mock
@@ -433,11 +430,8 @@ describe('useCardDelegation', () => {
describe('error handling', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const params = createMockDelegationParams();
diff --git a/app/components/UI/Card/hooks/useCardDetails.test.ts b/app/components/UI/Card/hooks/useCardDetails.test.ts
index a6dbb720d1c..3387afd95d3 100644
--- a/app/components/UI/Card/hooks/useCardDetails.test.ts
+++ b/app/components/UI/Card/hooks/useCardDetails.test.ts
@@ -33,7 +33,6 @@ const mockUseWrapWithCache = useWrapWithCache as jest.MockedFunction<
describe('useCardDetails', () => {
const mockGetCardDetails = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockFetchData = jest.fn();
const mockSDK = {
@@ -64,11 +63,8 @@ describe('useCardDetails', () => {
mockUseSelector.mockReturnValue(true); // isAuthenticated
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockUseWrapWithCache.mockReturnValue(mockCacheReturn);
@@ -150,7 +146,10 @@ describe('useCardDetails', () => {
expect(mockUseWrapWithCache).toHaveBeenCalledWith(
'card-details',
expect.any(Function),
- { cacheDuration: 60000 }, // AUTHENTICATED_CACHE_DURATION
+ {
+ cacheDuration: 60000, // AUTHENTICATED_CACHE_DURATION
+ fetchOnMount: false, // Manual fetch control
+ },
);
});
});
@@ -247,11 +246,8 @@ describe('useCardDetails', () => {
it('returns null when SDK is not available', async () => {
// Given: No SDK available
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
renderHook(() => useCardDetails());
@@ -425,7 +421,7 @@ describe('useCardDetails', () => {
// Then: Returns true, calls fetchCardDetails, and updates loading state
expect(pollResult).toBe(true);
expect(mockGetCardDetails).toHaveBeenCalledTimes(1);
- expect(mockFetchData).toHaveBeenCalledTimes(1); // Refresh after provisioning
+ expect(mockFetchData).toHaveBeenCalledTimes(2);
expect(result.current.isLoadingPollCardStatusUntilProvisioned).toBe(
false,
);
@@ -552,11 +548,8 @@ describe('useCardDetails', () => {
it('returns false when SDK is not available', async () => {
// Given: No SDK available
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useCardDetails());
diff --git a/app/components/UI/Card/hooks/useCardDetails.ts b/app/components/UI/Card/hooks/useCardDetails.ts
index 54737ba1d8b..2cdc43171e5 100644
--- a/app/components/UI/Card/hooks/useCardDetails.ts
+++ b/app/components/UI/Card/hooks/useCardDetails.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useCardSDK } from '../sdk';
import {
CardDetailsResponse,
@@ -63,14 +63,29 @@ const useCardDetails = () => {
}, [sdk, isAuthenticated]);
// Use cache wrapper for card details
+ const cacheResult = useWrapWithCache(
+ 'card-details',
+ fetchCardDetailsInternal,
+ {
+ cacheDuration: AUTHENTICATED_CACHE_DURATION, // 30 seconds cache
+ fetchOnMount: false,
+ },
+ );
+
const {
data: cardDetailsData,
isLoading,
error,
fetchData: fetchCardDetails,
- } = useWrapWithCache('card-details', fetchCardDetailsInternal, {
- cacheDuration: AUTHENTICATED_CACHE_DURATION, // 30 seconds cache
- });
+ } = cacheResult;
+
+ useEffect(() => {
+ if (sdk && isAuthenticated && !isLoading && !error && !cardDetailsData) {
+ fetchCardDetails();
+ }
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sdk, isAuthenticated, isLoading, error, cardDetailsData]);
// Poll logic to check if card is provisioned
// max polling attempts is 10, polling interval is 2 seconds
diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts
index 62ed3ff39a2..81d77a8daca 100644
--- a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts
+++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts
@@ -83,11 +83,8 @@ describe('useCardProviderAuthentication', () => {
});
mockGenerateState.mockReturnValue(mockStateUuid);
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSdk as unknown as CardSDK,
- isLoading: false,
- logoutFromProvider: jest.fn(),
- user: null,
- setUser: jest.fn(),
});
mockStrings.mockImplementation((key: string) => `mocked_${key}`);
mockUseDispatch.mockReturnValue(mockDispatch);
@@ -423,11 +420,8 @@ describe('useCardProviderAuthentication', () => {
it('throws error when SDK is not initialized', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- logoutFromProvider: jest.fn(),
- user: null,
- setUser: jest.fn(),
});
const loginParams = {
@@ -757,11 +751,8 @@ describe('useCardProviderAuthentication', () => {
it('throws error when SDK is not initialized', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- logoutFromProvider: jest.fn(),
- user: null,
- setUser: jest.fn(),
});
const otpParams = {
diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts
index 29ba5e0540e..8225a4c9b45 100644
--- a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts
+++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts
@@ -53,7 +53,7 @@ interface UseCardProviderAuthenticationResponse {
email: string;
password: string;
otpCode?: string;
- }) => Promise;
+ }) => Promise;
loading: boolean;
error: string | null;
clearError: () => void;
@@ -113,7 +113,7 @@ const useCardProviderAuthentication =
email: string;
password: string;
otpCode?: string;
- }): Promise => {
+ }): Promise => {
if (!sdk) {
throw new Error('Card SDK not initialized');
}
@@ -138,11 +138,7 @@ const useCardProviderAuthentication =
...(params.otpCode ? { otpCode: params.otpCode } : {}),
});
- if (
- loginResponse.isOtpRequired ||
- loginResponse.verificationState === 'PENDING' ||
- loginResponse.phase
- ) {
+ if (loginResponse.isOtpRequired || loginResponse.phase) {
return loginResponse;
}
@@ -174,6 +170,8 @@ const useCardProviderAuthentication =
setError(null);
dispatch(setIsAuthenticatedAction(true));
dispatch(setUserCardLocation(params.location));
+
+ return loginResponse;
} catch (err) {
const errorMessage = getErrorMessage(err);
setError(errorMessage);
diff --git a/app/components/UI/Card/hooks/useCardProvision.test.ts b/app/components/UI/Card/hooks/useCardProvision.test.ts
index e6cc4028893..6e6383e073c 100644
--- a/app/components/UI/Card/hooks/useCardProvision.test.ts
+++ b/app/components/UI/Card/hooks/useCardProvision.test.ts
@@ -18,7 +18,6 @@ const mockLogger = Logger as jest.Mocked;
describe('useCardProvision', () => {
const mockProvisionCard = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
provisionCard: mockProvisionCard,
@@ -34,11 +33,8 @@ describe('useCardProvision', () => {
// Default mock - SDK is available
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
});
@@ -194,11 +190,8 @@ describe('useCardProvision', () => {
describe('No SDK Available', () => {
it('returns early when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useCardProvision());
@@ -216,11 +209,8 @@ describe('useCardProvision', () => {
it('does not set loading state when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useCardProvision());
@@ -276,11 +266,8 @@ describe('useCardProvision', () => {
} as unknown as CardSDK;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: newMockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
rerender();
diff --git a/app/components/UI/Card/hooks/useEmailVerificationSend.test.ts b/app/components/UI/Card/hooks/useEmailVerificationSend.test.ts
index a4840dc259f..b8f8dab46e0 100644
--- a/app/components/UI/Card/hooks/useEmailVerificationSend.test.ts
+++ b/app/components/UI/Card/hooks/useEmailVerificationSend.test.ts
@@ -34,7 +34,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('useEmailVerificationSend', () => {
const mockEmailVerificationSend = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
emailVerificationSend: mockEmailVerificationSend,
@@ -47,11 +46,8 @@ describe('useEmailVerificationSend', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockGetErrorMessage.mockReturnValue('Mocked error message');
});
@@ -190,11 +186,8 @@ describe('useEmailVerificationSend', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useEmailVerificationSend());
@@ -390,11 +383,8 @@ describe('useEmailVerificationSend', () => {
describe('handles undefined SDK gracefully', () => {
it('throws appropriate error when SDK is undefined', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useEmailVerificationSend());
diff --git a/app/components/UI/Card/hooks/useEmailVerificationVerify.test.ts b/app/components/UI/Card/hooks/useEmailVerificationVerify.test.ts
index 986c0a7bf69..b20069572f1 100644
--- a/app/components/UI/Card/hooks/useEmailVerificationVerify.test.ts
+++ b/app/components/UI/Card/hooks/useEmailVerificationVerify.test.ts
@@ -35,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('useEmailVerificationVerify', () => {
const mockEmailVerificationVerify = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
emailVerificationVerify: mockEmailVerificationVerify,
@@ -67,11 +66,8 @@ describe('useEmailVerificationVerify', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockGetErrorMessage.mockReturnValue('Mocked error message');
});
@@ -203,11 +199,8 @@ describe('useEmailVerificationVerify', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useEmailVerificationVerify());
@@ -422,11 +415,8 @@ describe('useEmailVerificationVerify', () => {
describe('handles undefined SDK gracefully', () => {
it('throws appropriate error when SDK is undefined', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => useEmailVerificationVerify());
diff --git a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts
index fc6c518d225..ee7d1eed492 100644
--- a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts
+++ b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts
@@ -293,7 +293,6 @@ describe('mapCardExternalWalletDetailToCardTokenAllowance', () => {
describe('useGetCardExternalWalletDetails', () => {
const mockGetCardExternalWalletDetails = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockFetchData = jest.fn();
const mockSDK = {
@@ -366,11 +365,8 @@ describe('useGetCardExternalWalletDetails', () => {
mockUseSelector.mockReturnValue(true); // isAuthenticated
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockUseWrapWithCache.mockImplementation((_key, fetchFn, _options) => {
@@ -415,11 +411,8 @@ describe('useGetCardExternalWalletDetails', () => {
describe('Prerequisites Check', () => {
it('returns null when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
renderHook(() => useGetCardExternalWalletDetails(mockDelegationSettings));
@@ -639,11 +632,8 @@ describe('useGetCardExternalWalletDetails', () => {
it('does not trigger fetch when SDK is not available', () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockUseWrapWithCache.mockReturnValue({
@@ -740,11 +730,8 @@ describe('useGetCardExternalWalletDetails', () => {
// Start with SDK unavailable
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { rerender } = renderHook(() =>
@@ -755,11 +742,8 @@ describe('useGetCardExternalWalletDetails', () => {
// SDK becomes available
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
rerender();
diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
index e9570a417e3..44e0e813f16 100644
--- a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
+++ b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts
@@ -7,6 +7,7 @@ import {
DelegationSettingsResponse,
DelegationSettingsNetwork,
} from '../types';
+import { CardSDK } from '../sdk/CardSDK';
// Mock dependencies
jest.mock('react-redux', () => ({
@@ -29,7 +30,6 @@ const mockUseWrapWithCache = useWrapWithCache as jest.MockedFunction<
describe('useGetDelegationSettings', () => {
const mockGetDelegationSettings = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockFetchData = jest.fn();
const mockSDK = {
@@ -90,11 +90,8 @@ describe('useGetDelegationSettings', () => {
mockUseSelector.mockReturnValue(true); // isAuthenticated
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockUseWrapWithCache.mockImplementation((_key, fetchFn, _options) => {
@@ -158,11 +155,8 @@ describe('useGetDelegationSettings', () => {
describe('Prerequisites Check', () => {
it('returns null when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
renderHook(() => useGetDelegationSettings());
@@ -424,11 +418,8 @@ describe('useGetDelegationSettings', () => {
} as any;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: newMockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
rerender();
@@ -513,12 +504,8 @@ describe('useGetDelegationSettings', () => {
it('handles SDK method not available', async () => {
mockUseCardSDK.mockReturnValue({
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- sdk: {} as any, // SDK without getDelegationSettings method
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
+ ...jest.requireMock('../sdk'),
+ sdk: {} as unknown as CardSDK, // SDK wi thout getDelegationSettings method
});
renderHook(() => useGetDelegationSettings());
diff --git a/app/components/UI/Card/hooks/useGetUserKYCStatus.test.tsx b/app/components/UI/Card/hooks/useGetUserKYCStatus.test.tsx
new file mode 100644
index 00000000000..4eeec80a751
--- /dev/null
+++ b/app/components/UI/Card/hooks/useGetUserKYCStatus.test.tsx
@@ -0,0 +1,187 @@
+import React from 'react';
+import { renderHook, waitFor, act } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import useGetUserKYCStatus from './useGetUserKYCStatus';
+import { useCardSDK } from '../sdk';
+import { CardSDK } from '../sdk/CardSDK';
+import { CardError, CardErrorType } from '../types';
+import cardReducer from '../../../../core/redux/slices/card';
+
+jest.mock('../sdk');
+
+const mockUseCardSDK = useCardSDK as jest.MockedFunction;
+
+// Helper to create a test store
+const createTestStore = () =>
+ configureStore({
+ reducer: {
+ card: cardReducer,
+ },
+ });
+
+describe('useGetUserKYCStatus', () => {
+ const mockGetUserDetails = jest.fn();
+ const mockSdk = {
+ getUserDetails: mockGetUserDetails,
+ } as unknown as CardSDK;
+
+ const mockCardSDKContext = {
+ sdk: mockSdk,
+ isLoading: false,
+ user: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseCardSDK.mockReturnValue(mockCardSDKContext);
+ });
+
+ it('does not fetch when user is not authenticated', () => {
+ const store = createTestStore();
+ renderHook(() => useGetUserKYCStatus(false), {
+ wrapper: ({ children }) => {children},
+ });
+
+ expect(mockGetUserDetails).not.toHaveBeenCalled();
+ });
+
+ it('fetches KYC status when authenticated', async () => {
+ mockGetUserDetails.mockResolvedValue({
+ id: 'user-123',
+ verificationState: 'VERIFIED',
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ });
+ expect(result.current.error).toBeNull();
+ });
+
+ it('returns PENDING state when user verification is pending', async () => {
+ mockGetUserDetails.mockResolvedValue({
+ id: 'user-123',
+ verificationState: 'PENDING',
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.kycStatus?.verificationState).toBe('PENDING');
+ });
+ });
+
+ it('returns REJECTED state when user verification is rejected', async () => {
+ mockGetUserDetails.mockResolvedValue({
+ id: 'user-123',
+ verificationState: 'REJECTED',
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.kycStatus?.verificationState).toBe('REJECTED');
+ });
+ });
+
+ it('sets error and clears KYC status when API call fails', async () => {
+ const mockError = new CardError(
+ CardErrorType.SERVER_ERROR,
+ 'Failed to fetch',
+ );
+ mockGetUserDetails.mockRejectedValue(mockError);
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.error).toBeTruthy();
+ expect(result.current.kycStatus).toBeNull();
+ });
+
+ it('refetches KYC status when fetchKYCStatus is called manually', async () => {
+ mockGetUserDetails.mockResolvedValue({
+ id: 'user-123',
+ verificationState: 'VERIFIED',
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await result.current.fetchKYCStatus();
+ });
+
+ expect(mockGetUserDetails).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns null status when SDK is not available', () => {
+ mockUseCardSDK.mockReturnValue({
+ ...mockCardSDKContext,
+ sdk: null,
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ expect(result.current.kycStatus).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('sets verificationState to null when missing from API response', async () => {
+ mockGetUserDetails.mockResolvedValue({
+ id: 'user-123',
+ // verificationState is undefined
+ });
+
+ const store = createTestStore();
+ const { result } = renderHook(() => useGetUserKYCStatus(true), {
+ wrapper: ({ children }) => {children},
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: null,
+ userId: 'user-123',
+ });
+ });
+});
diff --git a/app/components/UI/Card/hooks/useGetUserKYCStatus.ts b/app/components/UI/Card/hooks/useGetUserKYCStatus.ts
new file mode 100644
index 00000000000..1b46a95aeec
--- /dev/null
+++ b/app/components/UI/Card/hooks/useGetUserKYCStatus.ts
@@ -0,0 +1,76 @@
+import { useCallback, useEffect } from 'react';
+import { useCardSDK } from '../sdk';
+import Logger from '../../../../util/Logger';
+import { CardVerificationState } from '../types';
+import { useWrapWithCache } from './useWrapWithCache';
+
+export interface UserKYCStatus {
+ verificationState: CardVerificationState | null;
+ userId: string | null;
+}
+
+interface UseGetUserKYCStatusResult {
+ kycStatus: UserKYCStatus | null;
+ isLoading: boolean;
+ error: Error | null;
+ fetchKYCStatus: () => Promise;
+}
+
+/**
+ * Hook to fetch user KYC verification status
+ * Only fetches when user is authenticated
+ *
+ * @param isAuthenticated - Whether the user is authenticated
+ * @returns {UseGetUserKYCStatusResult} KYC status data, loading state, error, and fetch function
+ */
+const useGetUserKYCStatus = (
+ isAuthenticated: boolean,
+): UseGetUserKYCStatusResult => {
+ const { sdk } = useCardSDK();
+
+ const fetchKYCStatusInternal = useCallback(async () => {
+ if (!isAuthenticated || !sdk) {
+ return null;
+ }
+
+ try {
+ const response = await sdk.getUserDetails();
+
+ return {
+ verificationState: response.verificationState ?? null,
+ userId: response.id,
+ };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err : new Error('Failed to fetch KYC status');
+ Logger.log('useGetUserKYCStatus: Error fetching KYC status', err);
+ throw errorMessage;
+ }
+ }, [sdk, isAuthenticated]);
+
+ const cacheResult = useWrapWithCache('kyc-status', fetchKYCStatusInternal, {
+ cacheDuration: 60 * 1000, // 60 seconds cache
+ fetchOnMount: false, // Disable auto-fetch, we'll manually control it below
+ });
+
+ const { data, isLoading, error, fetchData } = cacheResult;
+
+ // Manually trigger fetch when all prerequisites are ready
+ // This avoids the race condition where SDK isn't available on first render
+ useEffect(() => {
+ if (sdk && isAuthenticated && !isLoading && !error && !data) {
+ fetchData();
+ }
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sdk, isAuthenticated, isLoading, error, data]);
+
+ return {
+ kycStatus: data,
+ isLoading,
+ error,
+ fetchKYCStatus: fetchData,
+ };
+};
+
+export default useGetUserKYCStatus;
diff --git a/app/components/UI/Card/hooks/useIsCardholder.test.ts b/app/components/UI/Card/hooks/useIsCardholder.test.ts
deleted file mode 100644
index 9aa86b8274d..00000000000
--- a/app/components/UI/Card/hooks/useIsCardholder.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import { useSelector } from 'react-redux';
-
-import { useIsCardholder } from './useIsCardholder';
-import { selectIsCardholder } from '../../../../core/redux/slices/card';
-
-jest.mock('react-redux', () => ({
- useSelector: jest.fn(),
-}));
-
-jest.mock('../../../../core/redux/slices/card', () => ({
- selectIsCardholder: jest.fn(),
-}));
-
-const mockUseSelector = useSelector as jest.MockedFunction;
-
-describe('useIsCardholder', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('should return true when user is a cardholder', () => {
- mockUseSelector.mockReturnValue(true);
-
- const { result } = renderHook(() => useIsCardholder());
-
- expect(result.current).toBe(true);
- expect(mockUseSelector).toHaveBeenCalledWith(selectIsCardholder);
- expect(mockUseSelector).toHaveBeenCalledTimes(1);
- });
-
- it('should return false when user is not a cardholder', () => {
- mockUseSelector.mockReturnValue(false);
-
- const { result } = renderHook(() => useIsCardholder());
-
- expect(result.current).toBe(false);
- expect(mockUseSelector).toHaveBeenCalledWith(selectIsCardholder);
- expect(mockUseSelector).toHaveBeenCalledTimes(1);
- });
-
- it('should call useSelector with the correct selector', () => {
- mockUseSelector.mockReturnValue(false);
-
- renderHook(() => useIsCardholder());
-
- expect(mockUseSelector).toHaveBeenCalledWith(selectIsCardholder);
- });
-
- it('should return false when selector returns undefined or falsy values', () => {
- mockUseSelector.mockReturnValue(undefined);
- const { result: undefinedResult } = renderHook(() => useIsCardholder());
- expect(undefinedResult.current).toBe(false);
-
- mockUseSelector.mockReturnValue(null);
- const { result: nullResult } = renderHook(() => useIsCardholder());
- expect(nullResult.current).toBe(false);
-
- mockUseSelector.mockReturnValue('');
- const { result: emptyStringResult } = renderHook(() => useIsCardholder());
- expect(emptyStringResult.current).toBe(false);
-
- mockUseSelector.mockReturnValue(0);
- const { result: zeroResult } = renderHook(() => useIsCardholder());
- expect(zeroResult.current).toBe(false);
-
- expect(mockUseSelector).toHaveBeenCalledWith(selectIsCardholder);
- });
-
- it('should re-render when selector value changes', () => {
- mockUseSelector.mockReturnValue(false);
-
- const { result, rerender } = renderHook(() => useIsCardholder());
-
- expect(result.current).toBe(false);
-
- mockUseSelector.mockReturnValue(true);
- rerender();
-
- expect(result.current).toBe(true);
- });
-
- it('should maintain referential stability when selector value does not change', () => {
- mockUseSelector.mockReturnValue(true);
-
- const { result, rerender } = renderHook(() => useIsCardholder());
- const firstResult = result.current;
-
- rerender();
- const secondResult = result.current;
-
- expect(firstResult).toBe(secondResult);
- expect(firstResult).toBe(true);
- });
-});
diff --git a/app/components/UI/Card/hooks/useIsCardholder.ts b/app/components/UI/Card/hooks/useIsCardholder.ts
deleted file mode 100644
index 842e58fb2cc..00000000000
--- a/app/components/UI/Card/hooks/useIsCardholder.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { useSelector } from 'react-redux';
-import { selectIsCardholder } from '../../../../core/redux/slices/card';
-
-/**
- * Custom hook that returns whether the currently selected account is a cardholder.
- *
- * @returns boolean - true if the current account is a cardholder, false otherwise
- */
-export const useIsCardholder = (): boolean => !!useSelector(selectIsCardholder);
diff --git a/app/components/UI/Card/hooks/useLoadCardData.test.ts b/app/components/UI/Card/hooks/useLoadCardData.test.ts
index 849153e20e0..69cafb027d6 100644
--- a/app/components/UI/Card/hooks/useLoadCardData.test.ts
+++ b/app/components/UI/Card/hooks/useLoadCardData.test.ts
@@ -4,10 +4,10 @@ import useLoadCardData from './useLoadCardData';
import useIsBaanxLoginEnabled from './isBaanxLoginEnabled';
import useCardDetails from './useCardDetails';
import { useGetPriorityCardToken } from './useGetPriorityCardToken';
-import { useIsCardholder } from './useIsCardholder';
import useGetCardExternalWalletDetails from './useGetCardExternalWalletDetails';
import useGetDelegationSettings from './useGetDelegationSettings';
import useGetLatestAllowanceForPriorityToken from './useGetLatestAllowanceForPriorityToken';
+import useGetUserKYCStatus from './useGetUserKYCStatus';
import {
AllowanceState,
CardTokenAllowance,
@@ -28,10 +28,10 @@ jest.mock('react-redux', () => ({
jest.mock('./isBaanxLoginEnabled');
jest.mock('./useCardDetails');
jest.mock('./useGetPriorityCardToken');
-jest.mock('./useIsCardholder');
jest.mock('./useGetCardExternalWalletDetails');
jest.mock('./useGetDelegationSettings');
jest.mock('./useGetLatestAllowanceForPriorityToken');
+jest.mock('./useGetUserKYCStatus');
const mockUseIsBaanxLoginEnabled =
useIsBaanxLoginEnabled as jest.MockedFunction;
@@ -42,9 +42,6 @@ const mockUseGetPriorityCardToken =
useGetPriorityCardToken as jest.MockedFunction<
typeof useGetPriorityCardToken
>;
-const mockUseIsCardholder = useIsCardholder as jest.MockedFunction<
- typeof useIsCardholder
->;
const mockUseGetCardExternalWalletDetails =
useGetCardExternalWalletDetails as jest.MockedFunction<
typeof useGetCardExternalWalletDetails
@@ -57,6 +54,9 @@ const mockUseGetLatestAllowanceForPriorityToken =
useGetLatestAllowanceForPriorityToken as jest.MockedFunction<
typeof useGetLatestAllowanceForPriorityToken
>;
+const mockUseGetUserKYCStatus = useGetUserKYCStatus as jest.MockedFunction<
+ typeof useGetUserKYCStatus
+>;
describe('useLoadCardData', () => {
const mockPriorityToken: CardTokenAllowance = {
@@ -119,6 +119,7 @@ describe('useLoadCardData', () => {
const mockFetchExternalWalletDetails = jest.fn();
const mockFetchDelegationSettings = jest.fn();
const mockPollCardStatusUntilProvisioned = jest.fn();
+ const mockFetchKYCStatus = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
@@ -128,8 +129,6 @@ describe('useLoadCardData', () => {
mockUseIsBaanxLoginEnabled.mockReturnValue(true);
- mockUseIsCardholder.mockReturnValue(true);
-
mockUseGetDelegationSettings.mockReturnValue({
data: mockDelegationSettings,
isLoading: false,
@@ -169,6 +168,13 @@ describe('useLoadCardData', () => {
pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned,
isLoadingPollCardStatusUntilProvisioned: false,
});
+
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
});
describe('Unauthenticated Mode', () => {
@@ -330,7 +336,6 @@ describe('useLoadCardData', () => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isBaanxLoginEnabled).toBe(true);
- expect(result.current.isCardholder).toBe(true);
});
it('calls fetchPriorityToken when fetchAllData is invoked', async () => {
@@ -499,7 +504,6 @@ describe('useLoadCardData', () => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.isBaanxLoginEnabled).toBe(true);
- expect(result.current.isCardholder).toBe(true);
});
});
@@ -636,14 +640,6 @@ describe('useLoadCardData', () => {
expect(result.current.isBaanxLoginEnabled).toBe(false);
});
-
- it('handles non-cardholder state', () => {
- mockUseIsCardholder.mockReturnValue(false);
-
- const { result } = renderHook(() => useLoadCardData());
-
- expect(result.current.isCardholder).toBe(false);
- });
});
describe('Mode Switching', () => {
@@ -941,6 +937,342 @@ describe('useLoadCardData', () => {
expect(mockFetchPriorityToken).toHaveBeenCalledTimes(1);
expect(mockFetchCardDetails).toHaveBeenCalledTimes(1);
expect(mockFetchExternalWalletDetails).toHaveBeenCalledTimes(1);
+ expect(mockFetchKYCStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetchAllData includes delegation settings fetch for authenticated mode', async () => {
+ mockUseSelector.mockReturnValue(true); // Authenticated
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.refetchAllData();
+ });
+
+ expect(mockFetchDelegationSettings).toHaveBeenCalledTimes(1);
+ expect(mockFetchExternalWalletDetails).toHaveBeenCalledTimes(1);
+ expect(mockFetchCardDetails).toHaveBeenCalledTimes(1);
+ expect(mockFetchPriorityToken).toHaveBeenCalledTimes(1);
+ expect(mockFetchKYCStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetchAllData only fetches priority token in unauthenticated mode', async () => {
+ mockUseSelector.mockReturnValue(false); // Unauthenticated
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.refetchAllData();
+ });
+
+ expect(mockFetchPriorityToken).toHaveBeenCalledTimes(1);
+ expect(mockFetchDelegationSettings).not.toHaveBeenCalled();
+ expect(mockFetchExternalWalletDetails).not.toHaveBeenCalled();
+ expect(mockFetchCardDetails).not.toHaveBeenCalled();
+ expect(mockFetchKYCStatus).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('KYC Status', () => {
+ describe('Authenticated Mode', () => {
+ beforeEach(() => {
+ mockUseSelector.mockReturnValue(true); // Authenticated
+ });
+
+ it('returns KYC status when user is verified', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ });
+ });
+
+ it('returns KYC status when user verification is pending', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'PENDING',
+ userId: 'user-123',
+ });
+ });
+
+ it('returns KYC status when user verification is rejected', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'REJECTED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'REJECTED',
+ userId: 'user-123',
+ });
+ });
+
+ it('returns null KYC status when fetch fails', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: null,
+ isLoading: false,
+ error: new Error('KYC fetch failed'),
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toBeNull();
+ });
+
+ it('includes KYC status loading state in overall loading state', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: null,
+ isLoading: true,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('returns KYC error in combined error state', () => {
+ const kycError = new Error('KYC verification failed');
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: null,
+ isLoading: false,
+ error: kycError,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.error).toEqual(kycError);
+ });
+
+ it('returns null KYC status with null verification state', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: null, userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: null,
+ userId: 'user-123',
+ });
+ });
+
+ it('fetches KYC status when fetchAllData is called', async () => {
+ mockFetchKYCStatus.mockReset().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.fetchAllData();
+ });
+
+ expect(mockFetchKYCStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('fetches KYC status when refetchAllData is called', async () => {
+ mockFetchKYCStatus.mockReset().mockResolvedValue(undefined);
+ mockFetchDelegationSettings.mockReset().mockResolvedValue(undefined);
+ mockFetchExternalWalletDetails.mockReset().mockResolvedValue(undefined);
+ mockFetchCardDetails.mockReset().mockResolvedValue(undefined);
+ mockFetchPriorityToken.mockReset().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.refetchAllData();
+ });
+
+ expect(mockFetchKYCStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles KYC status update when status changes', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'PENDING', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result, rerender } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus?.verificationState).toBe('PENDING');
+
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ rerender();
+
+ expect(result.current.kycStatus?.verificationState).toBe('VERIFIED');
+ });
+ });
+
+ describe('Unauthenticated Mode', () => {
+ beforeEach(() => {
+ mockUseSelector.mockReturnValue(false); // Unauthenticated
+ });
+
+ it('returns null KYC status', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toBeNull();
+ });
+
+ it('excludes KYC loading state from overall loading state', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: null,
+ isLoading: true,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('excludes KYC error from combined error state', () => {
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: null,
+ isLoading: false,
+ error: new Error('KYC error'),
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.error).toBeFalsy();
+ });
+
+ it('does not fetch KYC status when fetchAllData is called', async () => {
+ mockFetchKYCStatus.mockReset().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.fetchAllData();
+ });
+
+ expect(mockFetchKYCStatus).not.toHaveBeenCalled();
+ });
+
+ it('does not fetch KYC status when refetchAllData is called', async () => {
+ mockFetchKYCStatus.mockReset().mockResolvedValue(undefined);
+ mockFetchPriorityToken.mockReset().mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ await act(async () => {
+ await result.current.refetchAllData();
+ });
+
+ expect(mockFetchKYCStatus).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Mode Switching', () => {
+ it('returns KYC status when switching from unauthenticated to authenticated', () => {
+ mockUseSelector.mockReturnValue(false); // Start unauthenticated
+
+ const { result, rerender } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toBeNull();
+
+ mockUseSelector.mockReturnValue(true); // Switch to authenticated
+
+ rerender();
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ });
+ });
+
+ it('returns null KYC status when switching from authenticated to unauthenticated', () => {
+ mockUseSelector.mockReturnValue(true); // Start authenticated
+ mockUseGetUserKYCStatus.mockReturnValue({
+ kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' },
+ isLoading: false,
+ error: null,
+ fetchKYCStatus: mockFetchKYCStatus,
+ });
+
+ const { result, rerender } = renderHook(() => useLoadCardData());
+
+ expect(result.current.kycStatus).toEqual({
+ verificationState: 'VERIFIED',
+ userId: 'user-123',
+ });
+
+ mockUseSelector.mockReturnValue(false); // Switch to unauthenticated
+
+ rerender();
+
+ expect(result.current.kycStatus).toBeNull();
+ });
+ });
+ });
+
+ describe('Warning Priority', () => {
+ it('returns NoCard warning when both NoCard and NeedDelegation warnings exist', () => {
+ mockUseGetPriorityCardToken.mockReturnValue({
+ priorityToken: mockPriorityToken,
+ allTokensWithAllowances: mockAllTokens,
+ isLoading: false,
+ error: false,
+ warning: CardWarning.NeedDelegation,
+ fetchPriorityToken: mockFetchPriorityToken,
+ });
+
+ mockUseCardDetails.mockReturnValue({
+ cardDetails: mockCardDetails,
+ isLoading: false,
+ error: null,
+ warning: CardWarning.NoCard,
+ fetchCardDetails: mockFetchCardDetails,
+ pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned,
+ isLoadingPollCardStatusUntilProvisioned: false,
+ });
+
+ const { result } = renderHook(() => useLoadCardData());
+
+ expect(result.current.warning).toBe(CardWarning.NoCard);
});
});
});
diff --git a/app/components/UI/Card/hooks/useLoadCardData.ts b/app/components/UI/Card/hooks/useLoadCardData.ts
index 8052de11e1f..50fd68a2fe4 100644
--- a/app/components/UI/Card/hooks/useLoadCardData.ts
+++ b/app/components/UI/Card/hooks/useLoadCardData.ts
@@ -4,10 +4,10 @@ import { selectIsAuthenticatedCard } from '../../../../core/redux/slices/card';
import useIsBaanxLoginEnabled from './isBaanxLoginEnabled';
import useCardDetails from './useCardDetails';
import { useGetPriorityCardToken } from './useGetPriorityCardToken';
-import { useIsCardholder } from './useIsCardholder';
import useGetCardExternalWalletDetails from './useGetCardExternalWalletDetails';
import useGetDelegationSettings from './useGetDelegationSettings';
import useGetLatestAllowanceForPriorityToken from './useGetLatestAllowanceForPriorityToken';
+import useGetUserKYCStatus from './useGetUserKYCStatus';
import { CardTokenAllowance, CardWarning } from '../types';
/**
@@ -40,7 +40,6 @@ import { CardTokenAllowance, CardWarning } from '../types';
const useLoadCardData = () => {
const isAuthenticated = useSelector(selectIsAuthenticatedCard);
const isBaanxLoginEnabled = useIsBaanxLoginEnabled();
- const isCardholder = useIsCardholder();
// Get delegation settings (only used in authenticated mode)
const {
@@ -80,6 +79,14 @@ const useLoadCardData = () => {
isAuthenticated ? priorityToken : null,
);
+ // Get user KYC status (authenticated mode only)
+ const {
+ kycStatus,
+ isLoading: isLoadingKYCStatus,
+ error: kycStatusError,
+ fetchKYCStatus,
+ } = useGetUserKYCStatus(isAuthenticated);
+
// Update priority token with latest allowance if available
const priorityTokenWithLatestAllowance = useMemo(() => {
if (!priorityToken || !isAuthenticated) {
@@ -124,7 +131,8 @@ const useLoadCardData = () => {
return (
baseLoading ||
isLoadingExternalWalletDetails ||
- isLoadingLatestAllowance
+ isLoadingLatestAllowance ||
+ isLoadingKYCStatus
);
}
return baseLoading;
@@ -134,24 +142,32 @@ const useLoadCardData = () => {
isLoadingDelegationSettings,
isLoadingExternalWalletDetails,
isLoadingLatestAllowance,
+ isLoadingKYCStatus,
isAuthenticated,
]);
// Combined error state
const error = useMemo(() => {
- const baseError =
- priorityTokenError || cardDetailsError || delegationSettingsError;
+ const baseError = priorityTokenError;
if (isAuthenticated) {
- return baseError || externalWalletDetailsError;
+ return (
+ baseError ||
+ externalWalletDetailsError ||
+ kycStatusError ||
+ delegationSettingsError ||
+ cardDetailsError
+ );
}
- return baseError;
+ // In unauthenticated mode, still check for delegation settings and card details errors
+ return baseError || delegationSettingsError || cardDetailsError;
}, [
priorityTokenError,
- cardDetailsError,
delegationSettingsError,
externalWalletDetailsError,
+ kycStatusError,
isAuthenticated,
+ cardDetailsError,
]);
// Combined warning (only from priority token and card details)
@@ -171,6 +187,7 @@ const useLoadCardData = () => {
fetchPriorityToken(),
fetchCardDetails(),
fetchExternalWalletDetails(),
+ fetchKYCStatus(),
]);
} else {
await Promise.all([fetchPriorityToken()]);
@@ -181,6 +198,7 @@ const useLoadCardData = () => {
fetchCardDetails,
isAuthenticated,
fetchExternalWalletDetails,
+ fetchKYCStatus,
],
);
@@ -193,6 +211,7 @@ const useLoadCardData = () => {
fetchExternalWalletDetails(),
fetchCardDetails(),
fetchPriorityToken(),
+ fetchKYCStatus(),
]);
} else {
await Promise.all([fetchPriorityToken()]);
@@ -204,6 +223,7 @@ const useLoadCardData = () => {
fetchExternalWalletDetails,
fetchCardDetails,
fetchPriorityToken,
+ fetchKYCStatus,
],
);
@@ -218,13 +238,14 @@ const useLoadCardData = () => {
externalWalletDetailsData: isAuthenticated
? externalWalletDetailsData
: null,
+ // KYC status (authenticated mode only)
+ kycStatus: isAuthenticated ? kycStatus : null,
// State flags
isLoading,
error,
warning,
isAuthenticated,
isBaanxLoginEnabled,
- isCardholder,
// Fetch functions
fetchAllData,
refetchAllData,
diff --git a/app/components/UI/Card/hooks/usePhoneVerificationSend.test.ts b/app/components/UI/Card/hooks/usePhoneVerificationSend.test.ts
index 06276c56d86..675ac7f1ebb 100644
--- a/app/components/UI/Card/hooks/usePhoneVerificationSend.test.ts
+++ b/app/components/UI/Card/hooks/usePhoneVerificationSend.test.ts
@@ -5,7 +5,6 @@ import {
PhoneVerificationSendResponse,
CardError,
CardErrorType,
- CardLocation,
} from '../types';
import { getErrorMessage } from '../util/getErrorMessage';
import usePhoneVerificationSend from './usePhoneVerificationSend';
@@ -36,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('usePhoneVerificationSend', () => {
const mockPhoneVerificationSend = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
phoneVerificationSend: mockPhoneVerificationSend,
@@ -53,12 +51,8 @@ describe('usePhoneVerificationSend', () => {
};
const mockCardSDKReturn = {
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
- userCardLocation: 'us' as CardLocation,
};
beforeEach(() => {
@@ -130,11 +124,8 @@ describe('usePhoneVerificationSend', () => {
it('should throw error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => usePhoneVerificationSend());
@@ -152,11 +143,8 @@ describe('usePhoneVerificationSend', () => {
it('should handle undefined SDK gracefully', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => usePhoneVerificationSend());
@@ -337,11 +325,8 @@ describe('usePhoneVerificationSend', () => {
it('should handle SDK method not available', async () => {
const sdkWithoutMethod = {} as CardSDK;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: sdkWithoutMethod,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => usePhoneVerificationSend());
diff --git a/app/components/UI/Card/hooks/usePhoneVerificationVerify.test.ts b/app/components/UI/Card/hooks/usePhoneVerificationVerify.test.ts
index 7684693051f..2a56af848d7 100644
--- a/app/components/UI/Card/hooks/usePhoneVerificationVerify.test.ts
+++ b/app/components/UI/Card/hooks/usePhoneVerificationVerify.test.ts
@@ -35,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('usePhoneVerificationVerify', () => {
const mockPhoneVerificationVerify = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
phoneVerificationVerify: mockPhoneVerificationVerify,
@@ -64,11 +63,8 @@ describe('usePhoneVerificationVerify', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
mockGetErrorMessage.mockReturnValue('Mocked error message');
});
@@ -140,11 +136,8 @@ describe('usePhoneVerificationVerify', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => usePhoneVerificationVerify());
@@ -370,11 +363,8 @@ describe('usePhoneVerificationVerify', () => {
it('handles undefined SDK gracefully', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
});
const { result } = renderHook(() => usePhoneVerificationVerify());
diff --git a/app/components/UI/Card/hooks/useRegisterMailingAddress.test.ts b/app/components/UI/Card/hooks/useRegisterMailingAddress.test.ts
index 7742809f6c4..270c343a4f7 100644
--- a/app/components/UI/Card/hooks/useRegisterMailingAddress.test.ts
+++ b/app/components/UI/Card/hooks/useRegisterMailingAddress.test.ts
@@ -35,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('useRegisterMailingAddress', () => {
const mockRegisterMailingAddress = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
registerMailingAddress: mockRegisterMailingAddress,
@@ -63,11 +62,8 @@ describe('useRegisterMailingAddress', () => {
};
const mockCardSDK: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
beforeEach(() => {
@@ -143,11 +139,8 @@ describe('useRegisterMailingAddress', () => {
it('throws error when SDK is not available', async () => {
const mockCardSDKUndefined: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
mockUseCardSDK.mockReturnValue(mockCardSDKUndefined);
@@ -333,7 +326,7 @@ describe('useRegisterMailingAddress', () => {
await act(async () => {
await result.current.registerAddress(mockAddressRequest);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -391,7 +384,7 @@ describe('useRegisterMailingAddress', () => {
await act(async () => {
await result.current.registerAddress(mockAddressRequest);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -439,11 +432,8 @@ describe('useRegisterMailingAddress', () => {
it('handles undefined SDK gracefully', async () => {
const mockCardSDKUndefined: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
mockUseCardSDK.mockReturnValue(mockCardSDKUndefined);
diff --git a/app/components/UI/Card/hooks/useRegisterPersonalDetails.test.ts b/app/components/UI/Card/hooks/useRegisterPersonalDetails.test.ts
index 19081a8b8be..4c37ace204b 100644
--- a/app/components/UI/Card/hooks/useRegisterPersonalDetails.test.ts
+++ b/app/components/UI/Card/hooks/useRegisterPersonalDetails.test.ts
@@ -35,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('useRegisterPersonalDetails', () => {
const mockRegisterPersonalDetails = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
registerPersonalDetails: mockRegisterPersonalDetails,
@@ -65,12 +64,8 @@ describe('useRegisterPersonalDetails', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
- userCardLocation: 'us',
} as ICardSDK);
});
@@ -146,12 +141,8 @@ describe('useRegisterPersonalDetails', () => {
it('handles SDK not available error', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
- userCardLocation: 'us',
} as ICardSDK);
const { result } = renderHook(() => useRegisterPersonalDetails());
@@ -350,7 +341,7 @@ describe('useRegisterPersonalDetails', () => {
mockPersonalDetailsRequest,
);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -418,7 +409,7 @@ describe('useRegisterPersonalDetails', () => {
mockPersonalDetailsRequest,
);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -462,15 +453,12 @@ describe('useRegisterPersonalDetails', () => {
});
it('handles undefined SDK gracefully', async () => {
- const mockCardSDKUndefined: ICardSDK = {
+ const mockCardSDKNull: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
- mockUseCardSDK.mockReturnValue(mockCardSDKUndefined);
+ mockUseCardSDK.mockReturnValue(mockCardSDKNull);
const { result } = renderHook(() => useRegisterPersonalDetails());
diff --git a/app/components/UI/Card/hooks/useRegisterPhysicalAddress.test.ts b/app/components/UI/Card/hooks/useRegisterPhysicalAddress.test.ts
index 7841d5f4cb0..b249b7f692c 100644
--- a/app/components/UI/Card/hooks/useRegisterPhysicalAddress.test.ts
+++ b/app/components/UI/Card/hooks/useRegisterPhysicalAddress.test.ts
@@ -35,7 +35,6 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction<
describe('useRegisterPhysicalAddress', () => {
const mockRegisterPhysicalAddress = jest.fn();
- const mockLogoutFromProvider = jest.fn();
const mockSDK = {
registerPhysicalAddress: mockRegisterPhysicalAddress,
@@ -66,11 +65,8 @@ describe('useRegisterPhysicalAddress', () => {
// Default mocks - SDK available
const mockCardSDK: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
mockUseCardSDK.mockReturnValue(mockCardSDK);
@@ -148,11 +144,8 @@ describe('useRegisterPhysicalAddress', () => {
it('handles SDK not available error', async () => {
const mockCardSDKNull: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
mockUseCardSDK.mockReturnValue(mockCardSDKNull);
@@ -339,7 +332,7 @@ describe('useRegisterPhysicalAddress', () => {
await act(async () => {
await result.current.registerAddress(mockAddressRequest);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -411,7 +404,7 @@ describe('useRegisterPhysicalAddress', () => {
await act(async () => {
await result.current.registerAddress(mockAddressRequest);
});
- } catch (error) {
+ } catch (err) {
// Expected to throw
}
@@ -458,11 +451,8 @@ describe('useRegisterPhysicalAddress', () => {
it('handles undefined SDK gracefully', async () => {
const mockCardSDKUndefined: ICardSDK = {
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: mockLogoutFromProvider,
};
mockUseCardSDK.mockReturnValue(mockCardSDKUndefined);
diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts
index aa1685071af..b0b01934440 100644
--- a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts
+++ b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts
@@ -58,11 +58,8 @@ describe('useRegisterUserConsent', () => {
// Default mocks
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockUseSelector.mockReturnValue('US'); // selectedCountry
@@ -137,11 +134,8 @@ describe('useRegisterUserConsent', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -336,11 +330,8 @@ describe('useRegisterUserConsent', () => {
describe('error handling', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -482,11 +473,8 @@ describe('useRegisterUserConsent', () => {
describe('error handling', () => {
it('throws error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -779,11 +767,8 @@ describe('useRegisterUserConsent', () => {
} as unknown as CardSDK;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: customSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -805,11 +790,8 @@ describe('useRegisterUserConsent', () => {
} as unknown as CardSDK;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: customSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -838,11 +820,8 @@ describe('useRegisterUserConsent', () => {
} as unknown as CardSDK;
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: customSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -858,11 +837,8 @@ describe('useRegisterUserConsent', () => {
it('handles SDK loading state', () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: true,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -880,11 +856,8 @@ describe('useRegisterUserConsent', () => {
describe('edge cases', () => {
it('handles undefined SDK gracefully for createOnboardingConsent', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -898,11 +871,8 @@ describe('useRegisterUserConsent', () => {
it('handles undefined SDK gracefully for linkUserToConsent', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useRegisterUserConsent());
@@ -988,14 +958,11 @@ describe('useRegisterUserConsent', () => {
// Change SDK dependency
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: {
createOnboardingConsent: jest.fn(),
linkUserToConsent: jest.fn(),
} as unknown as CardSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
rerender();
@@ -1013,15 +980,12 @@ describe('useRegisterUserConsent', () => {
// Change SDK dependency
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: {
createOnboardingConsent: jest.fn(),
linkUserToConsent: jest.fn(),
getConsentSetByOnboardingId: jest.fn(),
} as unknown as CardSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
rerender();
@@ -1038,15 +1002,12 @@ describe('useRegisterUserConsent', () => {
// Change SDK dependency
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: {
createOnboardingConsent: jest.fn(),
linkUserToConsent: jest.fn(),
getConsentSetByOnboardingId: jest.fn(),
} as unknown as CardSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
rerender();
diff --git a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts
index a2d343df2a5..03457c80286 100644
--- a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts
+++ b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts
@@ -44,11 +44,8 @@ describe('useRegistrationSettings', () => {
// Default mocks
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockUseWrapWithCache.mockReturnValue(mockCacheReturn);
@@ -93,11 +90,8 @@ describe('useRegistrationSettings', () => {
it('should throw error when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
renderHook(() => useRegistrationSettings());
@@ -188,11 +182,8 @@ describe('useRegistrationSettings', () => {
describe('edge cases', () => {
it('should handle undefined SDK gracefully', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
renderHook(() => useRegistrationSettings());
diff --git a/app/components/UI/Card/hooks/useStartVerification.test.ts b/app/components/UI/Card/hooks/useStartVerification.test.ts
index a40c7c919b8..866afaa57c3 100644
--- a/app/components/UI/Card/hooks/useStartVerification.test.ts
+++ b/app/components/UI/Card/hooks/useStartVerification.test.ts
@@ -46,11 +46,8 @@ describe('useStartVerification', () => {
// Default mocks
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockUseSelector.mockReturnValue('US'); // selectedCountry
mockGetErrorMessage.mockReturnValue('Mocked error message');
@@ -60,11 +57,8 @@ describe('useStartVerification', () => {
it('should initialize with correct default values', () => {
// Prevent auto-triggering by not providing SDK initially
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useStartVerification());
@@ -82,11 +76,8 @@ describe('useStartVerification', () => {
describe('startVerification function', () => {
it('should handle SDK not initialized error', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useStartVerification());
@@ -188,11 +179,8 @@ describe('useStartVerification', () => {
it('should reset states when starting new verification', async () => {
// First, set up an error state
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result, rerender } = renderHook(() => useStartVerification());
@@ -205,11 +193,8 @@ describe('useStartVerification', () => {
// Now provide SDK and start verification again
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockStartUserVerification.mockResolvedValue(
mockStartUserVerificationResponse,
@@ -231,11 +216,8 @@ describe('useStartVerification', () => {
describe('clearError function', () => {
it('should clear error state', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useStartVerification());
@@ -287,11 +269,8 @@ describe('useStartVerification', () => {
describe('auto-triggering on mount', () => {
it('should not auto-trigger when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockUseSelector.mockReturnValue('US');
@@ -415,11 +394,8 @@ describe('useStartVerification', () => {
it('should maintain state consistency across multiple operations', async () => {
// Mock to prevent auto-triggering on mount
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockUseSelector.mockReturnValue(null);
@@ -432,11 +408,8 @@ describe('useStartVerification', () => {
// Provide SDK for successful operation
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockStartUserVerification.mockResolvedValue(
mockStartUserVerificationResponse,
diff --git a/app/components/UI/Card/hooks/useUpdateTokenPriority.test.ts b/app/components/UI/Card/hooks/useUpdateTokenPriority.test.ts
index f40d0b1574d..b60cf7daad6 100644
--- a/app/components/UI/Card/hooks/useUpdateTokenPriority.test.ts
+++ b/app/components/UI/Card/hooks/useUpdateTokenPriority.test.ts
@@ -97,11 +97,8 @@ describe('useUpdateTokenPriority', () => {
};
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: mockSDK as unknown as CardSDK,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
mockOnSuccess = jest.fn();
@@ -164,11 +161,8 @@ describe('useUpdateTokenPriority', () => {
it('returns false when SDK is not available', async () => {
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() =>
useUpdateTokenPriority({
diff --git a/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts b/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts
index 28e14ac3185..6190d68d1c4 100644
--- a/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts
+++ b/app/components/UI/Card/hooks/useUserRegistrationStatus.test.ts
@@ -52,30 +52,26 @@ describe('useUserRegistrationStatus', () => {
jest.clearAllMocks();
jest.useFakeTimers();
- // Default mocks - handle multiple useSelector calls
- mockUseSelector.mockImplementation((selector) => {
- if (selector.toString().includes('selectOnboardingId')) {
- return 'onboarding-123';
- }
- return 'US'; // Default for other selectors like selectedCountry
- });
+ // Default mocks - always return onboarding-123 for selectOnboardingId
+ mockUseSelector.mockReturnValue('onboarding-123');
// Mock useDispatch
const mockDispatch = jest.fn();
mockUseDispatch.mockReturnValue(mockDispatch);
- // Default mock for getRegistrationStatus to handle auto-polling
+ // Default mock for getRegistrationStatus
mockGetRegistrationStatus.mockResolvedValue({
...mockUserResponse,
- verificationState: 'UNVERIFIED',
+ verificationState: 'PENDING',
});
mockUseCardSDK.mockReturnValue({
sdk: mockSDK,
- isLoading: false,
user: null,
setUser: jest.fn(),
+ isLoading: false,
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
mockGetErrorMessage.mockReturnValue('Mocked error message');
});
@@ -85,23 +81,11 @@ describe('useUserRegistrationStatus', () => {
});
describe('Initial State', () => {
- it('initializes with correct default state', async () => {
- // Given: Mock API response with PENDING verification state for initial auto-polling
- const pendingUserResponse: UserResponse = {
- ...mockUserResponse,
- verificationState: 'PENDING' as CardVerificationState,
- };
- mockGetRegistrationStatus.mockResolvedValue(pendingUserResponse);
-
- // When: Hook is rendered
- const { result, waitForNextUpdate } = renderHook(() =>
- useUserRegistrationStatus(),
- );
-
- // Wait for the initial auto-polling to complete
- await waitForNextUpdate();
+ it('initializes with correct default state', () => {
+ // When: Hook is rendered without user data
+ const { result } = renderHook(() => useUserRegistrationStatus());
- // Then: Initial state should have PENDING verification state and not be loading after initial fetch
+ // Then: Initial state defaults to PENDING and polling is not started
expect(result.current.verificationState).toBe('PENDING');
expect(result.current.isLoading).toBe(false);
expect(result.current.isError).toBe(false);
@@ -117,13 +101,18 @@ describe('useUserRegistrationStatus', () => {
// Given: Mock response
mockGetRegistrationStatus.mockResolvedValue(mockUserResponse);
- const { result } = renderHook(() => useUserRegistrationStatus());
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
// When: startPolling is called (which triggers fetchRegistrationStatus internally)
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
+ // Wait for the async operation to complete and state to update
+ await waitForNextUpdate();
+
// Then: Should have completed the fetch
expect(result.current.verificationState).toBe('VERIFIED');
expect(result.current.isLoading).toBe(false);
@@ -132,11 +121,8 @@ describe('useUserRegistrationStatus', () => {
it('handles SDK not initialized error', async () => {
// Given: SDK is not available
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
// When: Hook attempts to fetch
@@ -181,11 +167,8 @@ describe('useUserRegistrationStatus', () => {
it('clears error state', async () => {
// Given: Hook is in error state
mockUseCardSDK.mockReturnValue({
+ ...jest.requireMock('../sdk'),
sdk: null,
- isLoading: false,
- user: null,
- setUser: jest.fn(),
- logoutFromProvider: jest.fn(),
});
const { result } = renderHook(() => useUserRegistrationStatus());
@@ -214,16 +197,17 @@ describe('useUserRegistrationStatus', () => {
mockGetRegistrationStatus.mockResolvedValue(mockUserResponse);
// When: startPolling is called
- const { result } = renderHook(() => useUserRegistrationStatus());
-
- // Clear the initial auto-polling call
- mockGetRegistrationStatus.mockClear();
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
- // Then: Should fetch immediately
+ await waitForNextUpdate();
+
+ // Then: Fetches immediately
expect(mockGetRegistrationStatus).toHaveBeenCalledTimes(1);
expect(result.current.verificationState).toBe('VERIFIED');
});
@@ -319,8 +303,8 @@ describe('useUserRegistrationStatus', () => {
});
});
- describe('Auto-polling based on verification state', () => {
- it('starts polling when verification state is PENDING', async () => {
+ describe('Auto-stop polling based on verification state', () => {
+ it('continues polling when verification state is PENDING', async () => {
// Given: Response with PENDING verification state
const pendingResponse: UserResponse = {
...mockUserResponse,
@@ -328,7 +312,7 @@ describe('useUserRegistrationStatus', () => {
};
mockGetRegistrationStatus.mockResolvedValue(pendingResponse);
- // When: Hook is rendered and fetches data
+ // When: Polling is manually started
const { result } = renderHook(() => useUserRegistrationStatus());
await act(async () => {
@@ -337,7 +321,7 @@ describe('useUserRegistrationStatus', () => {
expect(result.current.verificationState).toBe('PENDING');
- // Clear the initial calls
+ // Clear the initial call
mockGetRegistrationStatus.mockClear();
// When: Time advances
@@ -349,50 +333,72 @@ describe('useUserRegistrationStatus', () => {
expect(mockGetRegistrationStatus).toHaveBeenCalledTimes(1);
});
- it('starts polling when verification state is UNVERIFIED', async () => {
- // Given: Response with UNVERIFIED verification state
- const unverifiedResponse: UserResponse = {
+ it('automatically stops polling when verification state is UNVERIFIED', async () => {
+ // Given: Initial PENDING response
+ const pendingResponse: UserResponse = {
...mockUserResponse,
- verificationState: 'UNVERIFIED',
+ verificationState: 'PENDING',
};
- mockGetRegistrationStatus.mockResolvedValue(unverifiedResponse);
+ mockGetRegistrationStatus.mockResolvedValueOnce(pendingResponse);
- // When: Hook is rendered and fetches data
- const { result } = renderHook(() => useUserRegistrationStatus());
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
+ await waitForNextUpdate();
+
+ expect(result.current.verificationState).toBe('PENDING');
+
+ // When: Next poll returns UNVERIFIED
+ const unverifiedResponse: UserResponse = {
+ ...mockUserResponse,
+ verificationState: 'UNVERIFIED',
+ };
+ mockGetRegistrationStatus.mockResolvedValueOnce(unverifiedResponse);
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+
+ await waitForNextUpdate();
+
+ // Then: Verification state updates and polling auto-stops
expect(result.current.verificationState).toBe('UNVERIFIED');
- // Clear the initial calls
+ // Clear previous calls
mockGetRegistrationStatus.mockClear();
- // When: Time advances
+ // When: More time passes
await act(async () => {
- jest.advanceTimersByTime(5000);
+ jest.advanceTimersByTime(10000);
});
- // Then: Should continue polling
- expect(mockGetRegistrationStatus).toHaveBeenCalledTimes(1);
+ // Then: Polling has stopped automatically (no more fetches)
+ expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});
- it('stops polling when verification state changes from PENDING to VERIFIED', async () => {
- // Given: Initial PENDING response for both auto-polling and manual startPolling
+ it('automatically stops polling when verification state changes from PENDING to VERIFIED', async () => {
+ // Given: Initial PENDING response
const pendingResponse: UserResponse = {
...mockUserResponse,
verificationState: 'PENDING',
};
- // Use mockResolvedValue instead of mockResolvedValueOnce to handle multiple calls
- mockGetRegistrationStatus.mockResolvedValue(pendingResponse);
+ mockGetRegistrationStatus.mockResolvedValueOnce(pendingResponse);
- const { result } = renderHook(() => useUserRegistrationStatus());
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
+ await waitForNextUpdate();
+
expect(result.current.verificationState).toBe('PENDING');
// When: Next poll returns VERIFIED
@@ -402,11 +408,13 @@ describe('useUserRegistrationStatus', () => {
};
mockGetRegistrationStatus.mockResolvedValueOnce(verifiedResponse);
- await act(async () => {
+ act(() => {
jest.advanceTimersByTime(5000);
});
- // Then: Should update state and stop polling
+ await waitForNextUpdate();
+
+ // Then: Verification state updates and polling auto-stops
expect(result.current.verificationState).toBe('VERIFIED');
// Clear previous calls
@@ -417,28 +425,47 @@ describe('useUserRegistrationStatus', () => {
jest.advanceTimersByTime(10000);
});
- // Then: Should not fetch again (polling stopped)
+ // Then: Polling has stopped automatically (no more fetches)
expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});
- it('stops polling when verification state changes to REJECTED', async () => {
- // Given: Response with REJECTED verification state
- const rejectedResponse: UserResponse = {
+ it('automatically stops polling when verification state changes to REJECTED', async () => {
+ // Given: Initial PENDING response
+ const pendingResponse: UserResponse = {
...mockUserResponse,
- verificationState: 'REJECTED',
+ verificationState: 'PENDING',
};
- mockGetRegistrationStatus.mockResolvedValue(rejectedResponse);
+ mockGetRegistrationStatus.mockResolvedValueOnce(pendingResponse);
- // When: Hook fetches REJECTED state
- const { result } = renderHook(() => useUserRegistrationStatus());
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
+ await waitForNextUpdate();
+
+ expect(result.current.verificationState).toBe('PENDING');
+
+ // When: Next poll returns REJECTED
+ const rejectedResponse: UserResponse = {
+ ...mockUserResponse,
+ verificationState: 'REJECTED',
+ };
+ mockGetRegistrationStatus.mockResolvedValueOnce(rejectedResponse);
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+
+ await waitForNextUpdate();
+
+ // Then: Verification state updates and polling auto-stops
expect(result.current.verificationState).toBe('REJECTED');
- // Clear the initial call
+ // Clear the initial calls
mockGetRegistrationStatus.mockClear();
// When: Time advances
@@ -446,7 +473,7 @@ describe('useUserRegistrationStatus', () => {
jest.advanceTimersByTime(10000);
});
- // Then: Should not continue polling
+ // Then: Polling has stopped automatically (no more fetches)
expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});
});
@@ -465,7 +492,6 @@ describe('useUserRegistrationStatus', () => {
result.current.startPolling();
});
- // Clear initial calls
mockGetRegistrationStatus.mockClear();
// When: Component unmounts
@@ -476,7 +502,7 @@ describe('useUserRegistrationStatus', () => {
jest.advanceTimersByTime(10000);
});
- // Then: Should not continue polling
+ // Then: Polling stops (cleanup on unmount)
expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});
});
@@ -495,12 +521,16 @@ describe('useUserRegistrationStatus', () => {
);
// When: Hook fetches data
- const { result } = renderHook(() => useUserRegistrationStatus());
+ const { result, waitForNextUpdate } = renderHook(() =>
+ useUserRegistrationStatus(),
+ );
- await act(async () => {
+ act(() => {
result.current.startPolling();
});
+ await waitForNextUpdate();
+
// Then: Should handle gracefully - verificationState should be 'PENDING' (default fallback)
expect(result.current.verificationState).toBe('PENDING');
expect(result.current.isError).toBe(false);
@@ -516,9 +546,6 @@ describe('useUserRegistrationStatus', () => {
const { result } = renderHook(() => useUserRegistrationStatus());
- // Clear the initial auto-polling call
- mockGetRegistrationStatus.mockClear();
-
// When: Multiple rapid startPolling calls
await act(async () => {
result.current.startPolling();
@@ -532,7 +559,7 @@ describe('useUserRegistrationStatus', () => {
result.current.startPolling();
});
- // Then: Should handle gracefully (each call triggers a fetch, but auto-polling is prevented)
+ // Then: Each call triggers a fetch, clearing previous interval
expect(mockGetRegistrationStatus).toHaveBeenCalledTimes(3);
// Clear previous calls
@@ -543,7 +570,7 @@ describe('useUserRegistrationStatus', () => {
jest.advanceTimersByTime(5000);
});
- // Then: Should only poll once (single active interval)
+ // Then: Only one active interval remains (last startPolling call)
expect(mockGetRegistrationStatus).toHaveBeenCalledTimes(1);
});
diff --git a/app/components/UI/Card/hooks/useUserRegistrationStatus.ts b/app/components/UI/Card/hooks/useUserRegistrationStatus.ts
index db3ed97e6c1..1f832282ca3 100644
--- a/app/components/UI/Card/hooks/useUserRegistrationStatus.ts
+++ b/app/components/UI/Card/hooks/useUserRegistrationStatus.ts
@@ -17,8 +17,10 @@ interface UseUserRegistrationStatusReturn {
/**
* Hook for polling user registration status
- * Automatically polls the registration status using getRegistrationStatus from CardSDK
- * at regular intervals when verificationState is PENDING
+ * Polls the registration status using getRegistrationStatus from CardSDK.
+ * Polling must be started manually via startPolling() and automatically stops
+ * when verification reaches a terminal state (VERIFIED, REJECTED, or UNVERIFIED).
+ * Only PENDING state continues polling.
*/
export const useUserRegistrationStatus =
(): UseUserRegistrationStatusReturn => {
@@ -93,19 +95,14 @@ export const useUserRegistrationStatus =
}
}, []);
- // Auto-manage polling based on verification state
useEffect(() => {
- if (
- verificationState === 'PENDING' ||
- verificationState === 'UNVERIFIED'
- ) {
- startPolling();
- } else if (intervalRef.current) {
+ // Auto-stop polling when verification reaches terminal state (not PENDING)
+ if (verificationState !== 'PENDING' && intervalRef.current) {
stopPolling();
}
return stopPolling;
- }, [verificationState, stopPolling, startPolling]);
+ }, [verificationState, stopPolling]);
return {
verificationState,
diff --git a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
index aa5237d48ec..f9d50606c72 100644
--- a/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
+++ b/app/components/UI/Card/routes/OnboardingNavigator.test.tsx
@@ -13,6 +13,7 @@ import OnboardingNavigator, {
import { useCardSDK } from '../sdk';
import { strings } from '../../../../../locales/i18n';
import { CardSDK } from '../sdk/CardSDK';
+import { useParams } from '../../../../util/navigation/navUtils';
// Mock dependencies
jest.mock('react-redux', () => ({
@@ -27,6 +28,11 @@ jest.mock('../../../../../locales/i18n', () => ({
strings: jest.fn((key: string) => `mocked_${key}`),
}));
+// Mock useParams from navUtils
+jest.mock('../../../../util/navigation/navUtils', () => ({
+ useParams: jest.fn(() => ({})),
+}));
+
// Mock @react-navigation/native
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
@@ -75,7 +81,10 @@ jest.mock('../components/Onboarding/KYCFailed', () => 'KYCFailed');
jest.mock('../components/Onboarding/PersonalDetails', () => 'PersonalDetails');
jest.mock('../components/Onboarding/PhysicalAddress', () => 'PhysicalAddress');
jest.mock('../components/Onboarding/MailingAddress', () => 'MailingAddress');
-jest.mock('../components/Onboarding/Complete', () => 'Complete');
+jest.mock(
+ '../components/Onboarding/VerifyingRegistration',
+ () => 'VerifyingRegistration',
+);
jest.mock('../components/Onboarding/KYCWebview', () => 'KYCWebview');
// Mock navigation options
@@ -140,7 +149,7 @@ jest.mock('../../../../constants/navigation/Routes', () => ({
PERSONAL_DETAILS: 'PERSONAL_DETAILS',
PHYSICAL_ADDRESS: 'PHYSICAL_ADDRESS',
MAILING_ADDRESS: 'MAILING_ADDRESS',
- COMPLETE: 'COMPLETE',
+ VERIFYING_REGISTRATION: 'VERIFYING_REGISTRATION',
WEBVIEW: 'WEBVIEW',
},
},
@@ -148,6 +157,7 @@ jest.mock('../../../../constants/navigation/Routes', () => ({
const mockUseSelector = useSelector as jest.MockedFunction;
const mockUseCardSDK = useCardSDK as jest.MockedFunction;
+const mockUseParams = useParams as jest.MockedFunction;
describe('OnboardingNavigator', () => {
const renderWithNavigation = (component: React.ReactElement) =>
@@ -174,7 +184,11 @@ describe('OnboardingNavigator', () => {
user: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
+
+ // Default mock for useParams - returns empty object (no route params)
+ mockUseParams.mockReturnValue({});
});
describe('Loading State', () => {
@@ -187,6 +201,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
});
@@ -215,6 +230,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -233,6 +249,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -244,17 +261,89 @@ describe('OnboardingNavigator', () => {
});
describe('when user verification state is PENDING', () => {
- it('returns VALIDATING_KYC route', () => {
+ it('returns VERIFY_IDENTITY route when firstName is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'PENDING',
+ countryOfNationality: 'US',
+ // firstName is undefined
},
isLoading: false,
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns VERIFY_IDENTITY route when countryOfNationality is missing', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'PENDING',
+ firstName: 'John',
+ // countryOfNationality is undefined
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns VERIFY_IDENTITY route when both firstName and countryOfNationality are missing', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'PENDING',
+ // firstName is undefined
+ // countryOfNationality is undefined
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns VALIDATING_KYC route when firstName and countryOfNationality exist', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'PENDING',
+ firstName: 'John',
+ countryOfNationality: 'US',
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -279,6 +368,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -301,6 +391,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -323,6 +414,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -346,6 +438,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -355,7 +448,7 @@ describe('OnboardingNavigator', () => {
expect(queryByTestId('activity-indicator')).toBeNull();
});
- it('returns COMPLETE route when firstName, countryOfNationality, and addressLine1 exist', () => {
+ it('returns MAILING_ADDRESS route when user is from US and mailingAddressLine1 is missing', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
@@ -363,12 +456,66 @@ describe('OnboardingNavigator', () => {
verificationState: 'VERIFIED',
firstName: 'John',
countryOfNationality: 'US',
+ countryOfResidence: 'us',
addressLine1: '123 Main St',
+ // mailingAddressLine1 is undefined
},
isLoading: false,
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns SIGN_UP route as fallback when user is from US and has all required data', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'VERIFIED',
+ firstName: 'John',
+ countryOfNationality: 'US',
+ countryOfResidence: 'us',
+ addressLine1: '123 Main St',
+ mailingAddressLine1: '456 Mail St',
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns SIGN_UP route as fallback when non-US user has all required data', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'VERIFIED',
+ firstName: 'John',
+ countryOfNationality: 'CA',
+ countryOfResidence: 'CA',
+ addressLine1: '123 Main St',
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -379,18 +526,66 @@ describe('OnboardingNavigator', () => {
});
});
- describe('when onboardingId exists but user verification is not PENDING or VERIFIED', () => {
- it('returns SET_PHONE_NUMBER route', () => {
+ describe('when user verification state is UNVERIFIED', () => {
+ it('returns SIGN_UP route when email is missing', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'UNVERIFIED',
+ // email is undefined
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns SET_PHONE_NUMBER route when email exists but phoneNumber is missing', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'UNVERIFIED',
+ email: 'test@example.com',
+ // phoneNumber is undefined
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation(
+ ,
+ );
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('returns VERIFY_IDENTITY route when email and phoneNumber exist', () => {
mockUseSelector.mockReturnValue('onboarding-123');
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'UNVERIFIED',
+ email: 'test@example.com',
+ phoneNumber: '+1234567890',
},
isLoading: false,
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -401,18 +596,21 @@ describe('OnboardingNavigator', () => {
});
});
- describe('when no onboardingId but user exists', () => {
- it('returns VERIFY_IDENTITY route', () => {
+ describe('when no onboardingId', () => {
+ it('returns SIGN_UP route even when user exists', () => {
mockUseSelector.mockReturnValue(null);
mockUseCardSDK.mockReturnValue({
user: {
id: 'user-123',
verificationState: 'UNVERIFIED',
+ email: 'test@example.com',
+ phoneNumber: '+1234567890',
},
isLoading: false,
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
const { queryByTestId } = renderWithNavigation(
@@ -437,6 +635,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
});
@@ -477,6 +676,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
jest.spyOn(Alert, 'alert');
@@ -615,6 +815,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
});
@@ -636,6 +837,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
renderWithNavigation();
@@ -653,6 +855,7 @@ describe('OnboardingNavigator', () => {
sdk: null,
setUser: jest.fn(),
logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
});
renderWithNavigation();
@@ -660,6 +863,204 @@ describe('OnboardingNavigator', () => {
expect(mockUseCardSDK).toHaveBeenCalled();
});
});
+
+ describe('useParams integration', () => {
+ it('calls useParams to get route parameters', () => {
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: null,
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ renderWithNavigation();
+
+ expect(mockUseParams).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('fetchUserData on mount', () => {
+ it('calls fetchUserData when onboardingId exists and user is null', () => {
+ const mockFetchUserData = jest.fn();
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: null,
+ isLoading: false,
+ sdk: {} as CardSDK,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: mockFetchUserData,
+ });
+
+ renderWithNavigation();
+
+ expect(mockFetchUserData).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchUserData when user already exists', () => {
+ const mockFetchUserData = jest.fn();
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123', verificationState: 'VERIFIED' },
+ isLoading: false,
+ sdk: {} as CardSDK,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: mockFetchUserData,
+ });
+
+ renderWithNavigation();
+
+ expect(mockFetchUserData).not.toHaveBeenCalled();
+ });
+
+ it('does not call fetchUserData when onboardingId is null', () => {
+ const mockFetchUserData = jest.fn();
+ mockUseSelector.mockReturnValue(null);
+ mockUseCardSDK.mockReturnValue({
+ user: null,
+ isLoading: false,
+ sdk: {} as CardSDK,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: mockFetchUserData,
+ });
+
+ renderWithNavigation();
+
+ expect(mockFetchUserData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('cardUserPhase routing', () => {
+ it('routes to SIGN_UP when cardUserPhase is ACCOUNT', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'ACCOUNT' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123', contactVerificationId: 'contact-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('routes to SET_PHONE_NUMBER when cardUserPhase is PHONE_NUMBER', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'PHONE_NUMBER' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('routes to PERSONAL_DETAILS when cardUserPhase is PERSONAL_INFORMATION', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'PERSONAL_INFORMATION' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('routes to PHYSICAL_ADDRESS when cardUserPhase is PHYSICAL_ADDRESS', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'PHYSICAL_ADDRESS' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('routes to MAILING_ADDRESS when cardUserPhase is MAILING_ADDRESS', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'MAILING_ADDRESS' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('routes to SIGN_UP when cardUserPhase is ACCOUNT and contactVerificationId is missing', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'ACCOUNT' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: { id: 'user-123' },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
+
+ it('prioritizes cardUserPhase over user verification state', () => {
+ mockUseParams.mockReturnValue({ cardUserPhase: 'PHONE_NUMBER' });
+ mockUseSelector.mockReturnValue('onboarding-123');
+ mockUseCardSDK.mockReturnValue({
+ user: {
+ id: 'user-123',
+ verificationState: 'VERIFIED',
+ firstName: 'John',
+ countryOfNationality: 'US',
+ addressLine1: '123 Main St',
+ },
+ isLoading: false,
+ sdk: null,
+ setUser: jest.fn(),
+ logoutFromProvider: jest.fn(),
+ fetchUserData: jest.fn(),
+ });
+
+ const { queryByTestId } = renderWithNavigation();
+
+ expect(queryByTestId('activity-indicator')).toBeNull();
+ });
});
describe('Internationalization', () => {
diff --git a/app/components/UI/Card/routes/OnboardingNavigator.tsx b/app/components/UI/Card/routes/OnboardingNavigator.tsx
index 0ce867fcbaa..f82e3100ad2 100644
--- a/app/components/UI/Card/routes/OnboardingNavigator.tsx
+++ b/app/components/UI/Card/routes/OnboardingNavigator.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import {
createStackNavigator,
StackNavigationOptions,
@@ -14,7 +14,6 @@ import KYCFailed from '../components/Onboarding/KYCFailed';
import PersonalDetails from '../components/Onboarding/PersonalDetails';
import PhysicalAddress from '../components/Onboarding/PhysicalAddress';
import MailingAddress from '../components/Onboarding/MailingAddress';
-import Complete from '../components/Onboarding/Complete';
import { cardAuthenticationNavigationOptions, headerStyle } from '.';
import { selectOnboardingId } from '../../../../core/redux/slices/card';
import { useSelector } from 'react-redux';
@@ -31,6 +30,9 @@ import Text, {
import { strings } from '../../../../../locales/i18n';
import { View, ActivityIndicator, Alert } from 'react-native';
import { Box } from '@metamask/design-system-react-native';
+import { useParams } from '../../../../util/navigation/navUtils';
+import { CardUserPhase } from '../types';
+
const Stack = createStackNavigator();
export const KYCModalNavigationOptions = ({
@@ -109,41 +111,95 @@ const ValidatingKYCNavigationOptions = ({
});
const OnboardingNavigator: React.FC = () => {
+ const { cardUserPhase } = useParams<{
+ cardUserPhase?: CardUserPhase;
+ }>();
const onboardingId = useSelector(selectOnboardingId);
- const { user, isLoading } = useCardSDK();
+ const { user, isLoading, fetchUserData } = useCardSDK();
+ const [isMounted, setIsMounted] = useState(false);
- const getInitialRouteName = useCallback(() => {
- if (!onboardingId || !user?.id) {
- return Routes.CARD.ONBOARDING.SIGN_UP;
- }
- if (user?.verificationState === 'PENDING') {
- return Routes.CARD.ONBOARDING.VALIDATING_KYC;
+ // Fetch fresh user data on mount if user data is missing
+ // This ensures we always have the most up-to-date onboarding information
+ // when the navigator is accessed
+ useEffect(() => {
+ if (!isMounted && onboardingId && !user) {
+ fetchUserData();
}
- if (user?.verificationState === 'VERIFIED') {
- if (!user?.firstName || !user?.countryOfNationality) {
+ setIsMounted(true);
+ // eslint-disable-next-line react-compiler/react-compiler
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Run only once on mount
+
+ const getInitialRouteName = useMemo(() => {
+ // Priority 1: Use cardUserPhase if provided (from login response)
+ if (cardUserPhase) {
+ if (cardUserPhase === 'ACCOUNT' || !user?.contactVerificationId) {
+ return Routes.CARD.ONBOARDING.SIGN_UP;
+ }
+ if (cardUserPhase === 'PHONE_NUMBER') {
+ return Routes.CARD.ONBOARDING.SET_PHONE_NUMBER;
+ }
+ if (cardUserPhase === 'PERSONAL_INFORMATION') {
return Routes.CARD.ONBOARDING.PERSONAL_DETAILS;
- } else if (!user?.addressLine1) {
+ }
+ if (cardUserPhase === 'PHYSICAL_ADDRESS') {
return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS;
}
- return Routes.CARD.ONBOARDING.COMPLETE;
+ if (cardUserPhase === 'MAILING_ADDRESS') {
+ return Routes.CARD.ONBOARDING.MAILING_ADDRESS;
+ }
}
- if (onboardingId) {
- return Routes.CARD.ONBOARDING.SET_PHONE_NUMBER;
+
+ // Priority 2: Use cached user data if available
+ if (user?.verificationState && onboardingId) {
+ if (user.verificationState === 'UNVERIFIED') {
+ if (!user?.email) {
+ return Routes.CARD.ONBOARDING.SIGN_UP;
+ }
+
+ if (!user?.phoneNumber) {
+ return Routes.CARD.ONBOARDING.SET_PHONE_NUMBER;
+ }
+
+ return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
+ }
+
+ if (user.verificationState === 'PENDING') {
+ if (!user.firstName || !user.countryOfNationality) {
+ return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
+ }
+
+ return Routes.CARD.ONBOARDING.VALIDATING_KYC;
+ }
+
+ if (user.verificationState === 'VERIFIED') {
+ if (!user?.firstName || !user?.countryOfNationality) {
+ return Routes.CARD.ONBOARDING.PERSONAL_DETAILS;
+ } else if (!user?.addressLine1) {
+ return Routes.CARD.ONBOARDING.PHYSICAL_ADDRESS;
+ } else if (
+ user?.countryOfResidence?.toLowerCase() === 'us' &&
+ !user?.mailingAddressLine1
+ ) {
+ return Routes.CARD.ONBOARDING.MAILING_ADDRESS;
+ }
+ }
}
- return Routes.CARD.ONBOARDING.VERIFY_IDENTITY;
- }, [onboardingId, user]);
- // Show loading indicator while SDK is initializing or user data is being fetched
- if (isLoading) {
+ // Default to SIGN_UP route if no user data is available
+ return Routes.CARD.ONBOARDING.SIGN_UP;
+ }, [user, cardUserPhase, onboardingId]);
+
+ if (isLoading && !user) {
return (
-
+
);
}
return (
-
+
{
component={MailingAddress}
options={cardAuthenticationNavigationOptions}
/>
-
{
component={OnboardingNavigator}
options={{ headerShown: false }}
/>
+
);
};
diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts
index 3fe6c70104a..cb0846c1789 100644
--- a/app/components/UI/Card/sdk/CardSDK.test.ts
+++ b/app/components/UI/Card/sdk/CardSDK.test.ts
@@ -14,6 +14,7 @@ import {
CardExchangeTokenResponse,
CardLocation,
CreateOnboardingConsentRequest,
+ UserResponse,
} from '../types';
import Logger from '../../../../util/Logger';
import { getCardBaanxToken } from '../util/cardTokenVault';
@@ -3498,4 +3499,248 @@ describe('CardSDK', () => {
expect(result).toBe(largeValue);
});
});
+
+ describe('getUserDetails', () => {
+ const mockUserDetails: UserResponse = {
+ id: 'user-123',
+ firstName: 'John',
+ lastName: 'Doe',
+ dateOfBirth: '1990-01-01',
+ email: 'john.doe@example.com',
+ verificationState: 'VERIFIED',
+ phoneNumber: '1234567890',
+ phoneCountryCode: '+1',
+ addressLine1: '123 Main St',
+ addressLine2: null,
+ city: 'New York',
+ usState: 'NY',
+ zip: '10001',
+ countryOfResidence: 'US',
+ countryOfNationality: 'US',
+ ssn: null,
+ createdAt: '2021-01-01',
+ };
+
+ beforeEach(() => {
+ (getCardBaanxToken as jest.Mock).mockResolvedValue({
+ success: true,
+ tokenData: { accessToken: 'mock-access-token' },
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should successfully retrieve user details', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(mockUserDetails),
+ });
+
+ const result = await cardSDK.getUserDetails();
+
+ expect(result).toEqual(mockUserDetails);
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.stringContaining('/v1/user'),
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ Authorization: 'Bearer mock-access-token',
+ 'Content-Type': 'application/json',
+ }),
+ }),
+ );
+ });
+
+ it('should throw CardError with INVALID_CREDENTIALS for 401 status', async () => {
+ const mockErrorResponse = {
+ message: 'Unauthorized access',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn().mockResolvedValue(mockErrorResponse),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.INVALID_CREDENTIALS,
+ message: mockErrorResponse.message,
+ });
+ });
+
+ it('should throw CardError with INVALID_CREDENTIALS for 403 status', async () => {
+ const mockErrorResponse = {
+ message: 'Forbidden access',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: jest.fn().mockResolvedValue(mockErrorResponse),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.INVALID_CREDENTIALS,
+ message: mockErrorResponse.message,
+ });
+ });
+
+ it('should throw CardError with INVALID_CREDENTIALS default message for 401 when no message in response', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn().mockResolvedValue({}),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.INVALID_CREDENTIALS,
+ message: 'Invalid credentials. Please try logging in again.',
+ });
+ });
+
+ it('should throw CardError with SERVER_ERROR for 500 status', async () => {
+ const mockErrorResponse = {
+ message: 'Internal server error',
+ };
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: jest.fn().mockResolvedValue(mockErrorResponse),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.SERVER_ERROR,
+ message: mockErrorResponse.message,
+ });
+ });
+
+ it('should throw CardError with SERVER_ERROR default message when no message in response', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: jest.fn().mockResolvedValue({}),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.SERVER_ERROR,
+ message: 'Failed to get user details. Please try again.',
+ });
+ });
+
+ it('should throw CardError with SERVER_ERROR for 404 status', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: jest.fn().mockResolvedValue({ message: 'User not found' }),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.SERVER_ERROR,
+ message: 'User not found',
+ });
+ });
+
+ it('should handle response body parsing error gracefully', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: jest.fn().mockRejectedValue(new Error('Parse error')),
+ });
+
+ await expect(cardSDK.getUserDetails()).rejects.toThrow(CardError);
+ await expect(cardSDK.getUserDetails()).rejects.toMatchObject({
+ type: CardErrorType.SERVER_ERROR,
+ message: 'Failed to get user details. Please try again.',
+ });
+ });
+
+ it('should log debug info on error when enableLogs is true', async () => {
+ const cardSDKWithLogs = new CardSDK({
+ cardFeatureFlag: mockCardFeatureFlag,
+ enableLogs: true,
+ });
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn().mockResolvedValue({ message: 'Unauthorized' }),
+ });
+
+ await expect(cardSDKWithLogs.getUserDetails()).rejects.toThrow(CardError);
+
+ expect(Logger.log).toHaveBeenCalledWith(
+ expect.stringContaining('CardSDK Debug Log - getUserDetails::error'),
+ expect.stringContaining('Status: 401'),
+ );
+ });
+
+ it('should use authenticated request with bearer token', async () => {
+ const mockAccessToken = 'test-bearer-token';
+ (getCardBaanxToken as jest.Mock).mockResolvedValue({
+ success: true,
+ tokenData: { accessToken: mockAccessToken },
+ });
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(mockUserDetails),
+ });
+
+ await cardSDK.getUserDetails();
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: `Bearer ${mockAccessToken}`,
+ }),
+ }),
+ );
+ });
+
+ it('should handle missing bearer token gracefully', async () => {
+ (getCardBaanxToken as jest.Mock).mockResolvedValue({
+ success: false,
+ });
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(mockUserDetails),
+ });
+
+ const result = await cardSDK.getUserDetails();
+
+ expect(result).toEqual(mockUserDetails);
+ // Should still make the request even without bearer token
+ expect(global.fetch).toHaveBeenCalled();
+ });
+
+ it('should handle bearer token retrieval error', async () => {
+ (getCardBaanxToken as jest.Mock).mockRejectedValue(
+ new Error('Token retrieval failed'),
+ );
+
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(mockUserDetails),
+ });
+
+ const result = await cardSDK.getUserDetails();
+
+ expect(result).toEqual(mockUserDetails);
+ expect(Logger.log).toHaveBeenCalledWith(
+ 'Failed to retrieve Card bearer token:',
+ expect.any(Error),
+ );
+ });
+ });
});
diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts
index 640ddbeb570..6ae61a6d893 100644
--- a/app/components/UI/Card/sdk/CardSDK.ts
+++ b/app/components/UI/Card/sdk/CardSDK.ts
@@ -816,6 +816,45 @@ export class CardSDK {
} as CardExchangeTokenResponse;
};
+ getUserDetails = async (): Promise => {
+ const response = await this.makeRequest(
+ '/v1/user',
+ { method: 'GET' },
+ true,
+ );
+
+ if (!response.ok) {
+ let responseBody = null;
+ try {
+ responseBody = await response.json();
+ } catch {
+ // If we can't parse response, continue without it
+ }
+
+ this.logDebugInfo(
+ 'getUserDetails::error',
+ `Status: ${response.status}, Message: ${JSON.stringify(responseBody, null, 2)}`,
+ );
+
+ if (response.status === 401 || response.status === 403) {
+ throw new CardError(
+ CardErrorType.INVALID_CREDENTIALS,
+ responseBody?.message ||
+ 'Invalid credentials. Please try logging in again.',
+ );
+ }
+
+ throw new CardError(
+ CardErrorType.SERVER_ERROR,
+ responseBody?.message ||
+ 'Failed to get user details. Please try again.',
+ );
+ }
+
+ const data = await response.json();
+ return data as UserResponse;
+ };
+
getCardDetails = async (): Promise => {
const response = await this.makeRequest(
'/v1/card/status',
diff --git a/app/components/UI/Card/sdk/index.test.tsx b/app/components/UI/Card/sdk/index.test.tsx
index f7722e9ed61..1a2296d7305 100644
--- a/app/components/UI/Card/sdk/index.test.tsx
+++ b/app/components/UI/Card/sdk/index.test.tsx
@@ -32,6 +32,7 @@ import {
import Logger from '../../../../util/Logger';
import { View } from 'react-native';
import { UserResponse } from '../types';
+import { getErrorMessage } from '../util/getErrorMessage';
jest.mock('./CardSDK', () => ({
CardSDK: jest.fn().mockImplementation(() => ({
@@ -102,6 +103,10 @@ jest.mock('../../../../util/Logger', () => ({
log: jest.fn(),
}));
+jest.mock('../util/getErrorMessage', () => ({
+ getErrorMessage: jest.fn(),
+}));
+
describe('CardSDK Context', () => {
const MockedCardholderSDK = jest.mocked(CardSDK);
const mockUseSelector = jest.mocked(useSelector);
@@ -115,6 +120,7 @@ describe('CardSDK Context', () => {
const mockStoreCardBaanxToken = jest.mocked(storeCardBaanxToken);
const mockRemoveCardBaanxToken = jest.mocked(removeCardBaanxToken);
const mockLogger = jest.mocked(Logger);
+ const mockGetErrorMessage = jest.mocked(getErrorMessage);
const mockSupportedTokens: SupportedToken[] = [
{
@@ -251,6 +257,7 @@ describe('CardSDK Context', () => {
isLoading: false,
user: null,
setUser: jest.fn(),
+ fetchUserData: jest.fn(),
logoutFromProvider: jest.fn(),
};
@@ -292,6 +299,7 @@ describe('CardSDK Context', () => {
logoutFromProvider: expect.any(Function),
user: null,
setUser: expect.any(Function),
+ fetchUserData: expect.any(Function),
});
});
@@ -714,5 +722,69 @@ describe('CardSDK Context', () => {
);
expect(result.current.user).toBe(null);
});
+
+ it('resets onboarding state when "Invalid onboarding ID" error occurs', async () => {
+ // Given: SDK that throws "Invalid onboarding ID" error
+ const mockError = new Error('Invalid onboarding ID');
+ const mockGetRegistrationStatus = jest.fn().mockRejectedValue(mockError);
+ setupMockSDK({ getRegistrationStatus: mockGetRegistrationStatus });
+ setupMockUseSelector(mockCardFeatureFlag, null, 'test-onboarding-id');
+
+ // Mock getErrorMessage to return the error message
+ mockGetErrorMessage.mockReturnValue('Invalid onboarding ID');
+
+ // When: provider initializes and fetches user data
+ const { result } = renderHook(() => useCardSDK(), {
+ wrapper: createWrapper,
+ });
+
+ // Then: error should be handled and onboarding state should be reset
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(mockGetRegistrationStatus).toHaveBeenCalledWith(
+ 'test-onboarding-id',
+ );
+ expect(mockGetErrorMessage).toHaveBeenCalledWith(mockError);
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'card/resetOnboardingState',
+ });
+ expect(result.current.user).toBe(null);
+ });
+
+ it('does not reset onboarding state for other errors', async () => {
+ // Given: SDK that throws a different error
+ const mockError = new Error('Network timeout');
+ const mockGetRegistrationStatus = jest.fn().mockRejectedValue(mockError);
+ setupMockSDK({ getRegistrationStatus: mockGetRegistrationStatus });
+ setupMockUseSelector(mockCardFeatureFlag, null, 'test-onboarding-id');
+
+ // Mock getErrorMessage to return a different error message
+ mockGetErrorMessage.mockReturnValue('Network timeout');
+
+ // Clear any previous dispatch calls
+ mockDispatch.mockClear();
+
+ // When: provider initializes and fetches user data
+ const { result } = renderHook(() => useCardSDK(), {
+ wrapper: createWrapper,
+ });
+
+ // Then: error should be handled but onboarding state should NOT be reset
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(mockGetRegistrationStatus).toHaveBeenCalledWith(
+ 'test-onboarding-id',
+ );
+ expect(mockGetErrorMessage).toHaveBeenCalledWith(mockError);
+ // Verify resetOnboardingState was NOT dispatched
+ expect(mockDispatch).not.toHaveBeenCalledWith({
+ type: 'card/resetOnboardingState',
+ });
+ expect(result.current.user).toBe(null);
+ });
});
});
diff --git a/app/components/UI/Card/sdk/index.tsx b/app/components/UI/Card/sdk/index.tsx
index c903bff6f02..3808b896d1e 100644
--- a/app/components/UI/Card/sdk/index.tsx
+++ b/app/components/UI/Card/sdk/index.tsx
@@ -22,8 +22,10 @@ import {
resetOnboardingState,
resetAuthenticatedData,
clearAllCache,
+ setContactVerificationId,
} from '../../../../core/redux/slices/card';
import { UserResponse } from '../types';
+import { getErrorMessage } from '../util/getErrorMessage';
// Types
export interface ICardSDK {
@@ -32,6 +34,7 @@ export interface ICardSDK {
user: UserResponse | null;
setUser: (user: UserResponse | null) => void;
logoutFromProvider: () => Promise;
+ fetchUserData: () => Promise;
}
interface ProviderProps {
@@ -61,10 +64,6 @@ export const CardSDKProvider = ({
// Add user state management
const [user, setUser] = useState(null);
- const removeAuthenticatedData = useCallback(() => {
- dispatch(resetAuthenticatedData());
- }, [dispatch]);
-
// Initialize CardSDK when feature flag is enabled
useEffect(() => {
if (cardFeatureFlag) {
@@ -82,26 +81,39 @@ export const CardSDKProvider = ({
setIsLoading(false);
}, [cardFeatureFlag, userCardLocation]);
- // Fetch user data on mount if onboardingId exists
- useEffect(() => {
- const fetchUserData = async () => {
- if (!sdk || !onboardingId) {
- return;
+ const fetchUserData = useCallback(async () => {
+ if (!sdk || !onboardingId) {
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const userData = await sdk.getRegistrationStatus(onboardingId);
+
+ if (userData.contactVerificationId) {
+ dispatch(setContactVerificationId(userData.contactVerificationId));
}
- setIsLoading(true);
- try {
- const userData = await sdk.getRegistrationStatus(onboardingId);
- setUser(userData);
- } catch {
- // Assume user is not registered
- } finally {
- setIsLoading(false);
+ setUser(userData);
+ } catch (err) {
+ const errorMessage = getErrorMessage(err);
+ if (errorMessage?.includes('Invalid onboarding ID')) {
+ dispatch(resetOnboardingState());
}
- };
+ } finally {
+ setIsLoading(false);
+ }
+ }, [sdk, onboardingId, dispatch]);
+
+ // Fetch user data on mount if onboardingId exists
+ useEffect(() => {
+ if (!sdk || !onboardingId) {
+ return;
+ }
fetchUserData();
- }, [sdk, onboardingId]);
+ }, [sdk, onboardingId, fetchUserData]);
const logoutFromProvider = useCallback(async () => {
if (!sdk) {
@@ -109,17 +121,11 @@ export const CardSDKProvider = ({
}
await removeCardBaanxToken();
- removeAuthenticatedData();
-
- // Clear all cached data (card details, priority tokens, etc.)
+ dispatch(resetAuthenticatedData());
dispatch(clearAllCache());
-
- // reset onboarding state
dispatch(resetOnboardingState());
-
- // Clear user data from context
setUser(null);
- }, [sdk, removeAuthenticatedData, dispatch]);
+ }, [sdk, dispatch]);
// Memoized context value to prevent unnecessary re-renders
const contextValue = useMemo(
@@ -129,8 +135,9 @@ export const CardSDKProvider = ({
user,
setUser,
logoutFromProvider,
+ fetchUserData,
}),
- [sdk, isLoading, user, setUser, logoutFromProvider],
+ [sdk, isLoading, user, setUser, logoutFromProvider, fetchUserData],
);
return ;
diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts
index 8ef5d4555cf..5cc78783dcd 100644
--- a/app/components/UI/Card/types.ts
+++ b/app/components/UI/Card/types.ts
@@ -271,26 +271,28 @@ export interface RegisterAddressResponse {
export interface UserResponse {
id: string;
- firstName?: string;
- lastName?: string;
- dateOfBirth?: string; // Format: YYYY-MM-DD
- email?: string;
+ firstName?: string | null;
+ lastName?: string | null;
+ dateOfBirth?: string | null; // Format: YYYY-MM-DD
+ email?: string | null;
verificationState?: CardVerificationState;
- phoneNumber?: string; // Format: 2345678901
- phoneCountryCode?: string; // Format: +1
- addressLine1?: string;
- addressLine2?: string;
- city?: string;
- zip?: string;
- usState?: string; // Required for US users
- countryOfResidence?: string; // ISO 3166-1 alpha-2 country code
- countryOfNationality?: string; // ISO 3166-1 alpha-2 country code
- ssn?: string; // Required for US users only
- mailingAddressLine1?: string;
- mailingAddressLine2?: string;
- mailingCity?: string;
- mailingZip?: string;
- mailingUsState?: string; // Required for US users
+ phoneNumber?: string | null; // Format: 2345678901
+ phoneCountryCode?: string | null; // Format: +1
+ addressLine1?: string | null;
+ addressLine2?: string | null;
+ city?: string | null;
+ zip?: string | null;
+ usState?: string | null; // Required for US users
+ countryOfResidence?: string | null; // ISO 3166-1 alpha-2 country code
+ countryOfNationality?: string | null; // ISO 3166-1 alpha-2 country code
+ ssn?: string | null; // Required for US users only
+ mailingAddressLine1?: string | null;
+ mailingAddressLine2?: string | null;
+ mailingCity?: string | null;
+ mailingZip?: string | null;
+ mailingUsState?: string | null; // Required for US users
+ contactVerificationId?: string | null;
+ createdAt?: string | null;
}
// Country type definition
diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts
index 6ff24b2aa0c..40f1e342d2c 100644
--- a/app/components/UI/Card/util/metrics.ts
+++ b/app/components/UI/Card/util/metrics.ts
@@ -14,6 +14,7 @@ enum CardScreens {
PERSONAL_DETAILS = 'PERSONAL_DETAILS',
RESIDENTIAL_ADDRESS = 'RESIDENTIAL_ADDRESS',
MAILING_ADDRESS = 'MAILING_ADDRESS',
+ VERIFYING_REGISTRATION = 'VERIFYING_REGISTRATION',
COMPLETE = 'COMPLETE',
ENABLE_TOKEN = 'ENABLE_TOKEN',
SPENDING_LIMIT_WARNING = 'SPENDING_LIMIT_WARNING',
@@ -33,6 +34,8 @@ enum CardActions {
CONFIRM_PHONE_NUMBER_RESEND_BUTTON = 'CONFIRM_PHONE_NUMBER_RESEND_BUTTON',
VERIFY_IDENTITY_BUTTON = 'VERIFY_IDENTITY_BUTTON',
VALIDATING_KYC_BUTTON = 'VALIDATING_KYC_BUTTON',
+ VERIFYING_REGISTRATION_CLOSE_BUTTON = 'VERIFYING_REGISTRATION_CLOSE_BUTTON',
+ VERIFYING_REGISTRATION_CONTINUE_BUTTON = 'VERIFYING_REGISTRATION_CONTINUE_BUTTON',
PERSONAL_DETAILS_BUTTON = 'PERSONAL_DETAILS_BUTTON',
RESIDENTIAL_ADDRESS_BUTTON = 'RESIDENTIAL_ADDRESS_BUTTON',
MAILING_ADDRESS_BUTTON = 'MAILING_ADDRESS_BUTTON',
diff --git a/app/components/UI/Card/util/validateDateOfBirth.test.ts b/app/components/UI/Card/util/validateDateOfBirth.test.ts
index 97c8bef7950..118a349316b 100644
--- a/app/components/UI/Card/util/validateDateOfBirth.test.ts
+++ b/app/components/UI/Card/util/validateDateOfBirth.test.ts
@@ -125,6 +125,44 @@ describe('validateDateOfBirth', () => {
});
});
+ describe('when handling dates before 1970 (negative timestamps)', () => {
+ it('returns true for person born in 1959 (negative timestamp)', () => {
+ // Given: date of birth on September 16, 1959
+ const dateIn1959 = new Date('1959-09-16T00:00:00.000Z');
+ const timestamp = dateIn1959.getTime(); // This will be negative
+
+ // When: validating date of birth
+ const result = validateDateOfBirth(timestamp);
+
+ // Then: should be valid (person is 65 years old in 2024)
+ expect(result).toBe(true);
+ });
+
+ it('returns true for person born in 1950', () => {
+ // Given: date of birth in 1950
+ const dateIn1950 = new Date('1950-06-15T00:00:00.000Z');
+ const timestamp = dateIn1950.getTime(); // This will be negative
+
+ // When: validating date of birth
+ const result = validateDateOfBirth(timestamp);
+
+ // Then: should be valid (person is 73 years old in 2024)
+ expect(result).toBe(true);
+ });
+
+ it('returns true for person born on January 1, 1900', () => {
+ // Given: very old date of birth
+ const veryOldDate = new Date('1900-01-01T00:00:00.000Z');
+ const timestamp = veryOldDate.getTime(); // This will be very negative
+
+ // When: validating date of birth
+ const result = validateDateOfBirth(timestamp);
+
+ // Then: should be valid (person is 124 years old in 2024)
+ expect(result).toBe(true);
+ });
+ });
+
describe('when handling invalid inputs', () => {
it('returns false for null timestamp', () => {
// When: validating null timestamp
@@ -142,22 +180,6 @@ describe('validateDateOfBirth', () => {
expect(result).toBe(false);
});
- it('returns false for zero timestamp', () => {
- // When: validating zero timestamp
- const result = validateDateOfBirth(0);
-
- // Then: should be invalid
- expect(result).toBe(false);
- });
-
- it('returns false for negative timestamp', () => {
- // When: validating negative timestamp
- const result = validateDateOfBirth(-1000);
-
- // Then: should be invalid
- expect(result).toBe(false);
- });
-
it('returns false for invalid date timestamp', () => {
// When: validating invalid date timestamp
const result = validateDateOfBirth(NaN);
@@ -289,6 +311,30 @@ describe('formatDateOfBirth', () => {
expect(result).toBe('1900-01-01');
});
+ it('formats dates before 1970 with negative timestamps correctly', () => {
+ // Given: date before Unix epoch (September 16, 1959)
+ const dateIn1959 = new Date(1959, 8, 16); // Month is 0-indexed, so 8 = September
+ const timestampString = dateIn1959.getTime().toString();
+
+ // When: formatting timestamp
+ const result = formatDateOfBirth(timestampString);
+
+ // Then: should return correct date
+ expect(result).toBe('1959-09-16');
+ });
+
+ it('formats dates from 1950 with negative timestamps correctly', () => {
+ // Given: date from 1950
+ const dateIn1950 = new Date(1950, 5, 15); // Month is 0-indexed, so 5 = June
+ const timestampString = dateIn1950.getTime().toString();
+
+ // When: formatting timestamp
+ const result = formatDateOfBirth(timestampString);
+
+ // Then: should return correct date
+ expect(result).toBe('1950-06-15');
+ });
+
it('formats recent dates correctly', () => {
// Given: recent date
const recentDate = new Date('2023-12-31T23:59:59.999Z');
diff --git a/app/components/UI/Card/util/validateDateOfBirth.ts b/app/components/UI/Card/util/validateDateOfBirth.ts
index 31d7d1dc67d..c1b366b5b52 100644
--- a/app/components/UI/Card/util/validateDateOfBirth.ts
+++ b/app/components/UI/Card/util/validateDateOfBirth.ts
@@ -4,7 +4,12 @@
* @returns boolean - true if the person is 18 or older, false otherwise
*/
export const validateDateOfBirth = (dateOfBirthTimestamp: number): boolean => {
- if (!dateOfBirthTimestamp || dateOfBirthTimestamp <= 0) {
+ // Check for invalid/missing timestamp (but allow negative timestamps for dates before 1970)
+ if (
+ dateOfBirthTimestamp === null ||
+ dateOfBirthTimestamp === undefined ||
+ isNaN(dateOfBirthTimestamp)
+ ) {
return false;
}
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 7912c685463..fc49987f7fc 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -376,6 +376,7 @@ const Routes = {
NOTIFICATION: 'CardNotification',
SPENDING_LIMIT: 'CardSpendingLimit',
CHANGE_ASSET: 'CardChangeAsset',
+ VERIFYING_REGISTRATION: 'VerifyingRegistration',
ONBOARDING: {
ROOT: 'CardOnboarding',
SIGN_UP: 'CardOnboardingSignUp',
diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts
index ddbcbf10054..ba821a3ec5b 100644
--- a/app/core/redux/slices/card/index.ts
+++ b/app/core/redux/slices/card/index.ts
@@ -292,6 +292,11 @@ export const selectCardGeoLocation = createSelector(
(card) => card.geoLocation,
);
+export const selectHasCardholderAccounts = createSelector(
+ selectCardholderAccounts,
+ (cardholderAccounts) => cardholderAccounts.length > 0,
+);
+
export const selectIsCardholder = createSelector(
selectCardholderAccounts,
selectedAccount,
diff --git a/locales/languages/en.json b/locales/languages/en.json
index 29e750f9be1..5a01dcef058 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6326,6 +6326,22 @@
"title": "Validating your KYC...",
"timeout_button": "Restart verification"
},
+ "verifying_registration": {
+ "title": "Verifying your identity",
+ "description": "Crypto Life is attempting to verify you. This will take at most 30 seconds.",
+ "verified_title": "Approved!",
+ "verified_description": "Your KYC was approved. You can now use MetaMask Card.",
+ "continue_button": "Continue",
+ "timeout_title": "Verification in progress",
+ "timeout_description": "Your verification will take up to 12 hours. Please come back to the Card section in that time or contact support at\n{{email}}\nfor assistance.",
+ "rejected_title": "Verification incomplete",
+ "rejected_description": "We need a bit more information to complete your verification.",
+ "rejected_message": "Please reach out to our support team and we'll help you complete the process:\n{{email}}",
+ "server_error_title_main": "Something went wrong",
+ "server_error_title": "We're experiencing server issues",
+ "server_error_message": "Please try again later or contact support at\n{{email}}\nfor assistance.",
+ "success_toast": "Your ID was verified!"
+ },
"kyc_failed": {
"title": "Rejected",
"description": "Your KYC was rejected. Please try again."
@@ -6442,6 +6458,26 @@
"description": "Please contact support to unblock your card"
}
},
+ "kyc_status": {
+ "pending": {
+ "title": "Verification in Progress",
+ "description": "Your identity verification is being processed. This usually takes a few minutes. Please check back shortly to enable your card."
+ },
+ "rejected": {
+ "title": "Verification Not Approved",
+ "description": "We were unable to verify your identity. Please contact support for assistance.",
+ "support_description": "We were unable to verify your identity at this time. Please contact our support team at {{email}} for assistance and we'll help you resolve this issue."
+ },
+ "unverified": {
+ "title": "Verification Required",
+ "description": "You need to complete identity verification before enabling your card. Please contact support team at {{email}} for assistance."
+ },
+ "error": {
+ "title": "Verification Status Unavailable",
+ "description": "We couldn't check your verification status. Please try again later or contact support if the issue persists."
+ },
+ "ok_button": "OK"
+ },
"manage_card_options": {
"manage_spending_limit": "Manage spending limit",
"manage_spending_limit_description_restricted": "Limited spending is on",