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: image 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** Simulator Screenshot - iPhone 16 Pro Max -
2025-11-06 at 12 28 10 ### **After** Simulator Screenshot - iPhone 16 Pro
Max - 2025-11-06 at 12 28 00 ## **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')} - - - )} - - - - -