diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 6d3af39e63f0..5e401008299c 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -38,9 +38,8 @@ import Toast, { } from '../../../component-library/components/Toast'; import AccountSelector from '../../../components/Views/AccountSelector'; import AddressSelector from '../../../components/Views/AddressSelector'; -import { TokenSortBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet'; +import { TokenSortBottomSheet } from '../../UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet'; import ProfilerManager from '../../../components/UI/ProfilerManager'; -import { TokenFilterBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet'; import NetworkManager from '../../../components/UI/NetworkManager'; import { AccountPermissionsScreens } from '../../../components/Views/AccountPermissions/AccountPermissions.types'; import AccountPermissionsConfirmRevokeAll from '../../../components/Views/AccountPermissions/AccountPermissionsConfirmRevokeAll'; @@ -485,10 +484,6 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.SHEET.TOKEN_SORT} component={TokenSortBottomSheet} /> - ({ diff --git a/app/components/UI/Bridge/hooks/useRecipientInitialization.ts b/app/components/UI/Bridge/hooks/useRecipientInitialization.ts index cc957becd763..f95817dcb6ca 100644 --- a/app/components/UI/Bridge/hooks/useRecipientInitialization.ts +++ b/app/components/UI/Bridge/hooks/useRecipientInitialization.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { selectDestAddress, @@ -7,11 +7,8 @@ import { } from '../../../../core/redux/slices/bridge'; import { CaipAccountId, parseCaipAccountId } from '@metamask/utils'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { - isNonEvmAddress, - isNonEvmChainId, -} from '../../../../core/Multichain/utils'; import { useDestinationAccounts } from './useDestinationAccounts'; +import { areAddressesEqual } from '../../../../util/address'; export const useRecipientInitialization = ( hasInitializedRecipient: React.MutableRefObject, @@ -33,6 +30,26 @@ export const useRecipientInitialization = ( [dispatch], ); + // Check if current destAddress is a valid destination account for the current destination chain + // This properly handles switching between different non-EVM chains (e.g., BTC → SOL) + // by checking if the address exists in the filtered destination accounts list + const isDestAddressValidForDestChain = useMemo(() => { + if ( + !destAddress || + !destToken?.chainId || + destinationAccounts.length === 0 + ) { + return false; + } + + // Check if the current destAddress matches any of the valid destination accounts + // destinationAccounts is already filtered by selectValidDestInternalAccountIds + // which uses account scopes to filter for the specific destination chain + return destinationAccounts.some((account) => + areAddressesEqual(account.address, destAddress), + ); + }, [destAddress, destToken?.chainId, destinationAccounts]); + // Initialize default recipient account useEffect(() => { // Only initialize if we haven't done so before, or if the current address doesn't match the network type @@ -40,25 +57,12 @@ export const useRecipientInitialization = ( return; } - // Check if current destAddress matches the destination chain type - const isDestChainNonEvm = - destToken?.chainId && isNonEvmChainId(destToken.chainId); - const isDestAddressNonEvm = destAddress && isNonEvmAddress(destAddress); - - // Address format should match the destination chain type: - // - If dest chain is non-EVM (e.g., Solana, Bitcoin), dest address should be non-EVM - // - If dest chain is EVM, dest address should be EVM - const doesDestAddrMatchNetworkType = - destAddress && - destToken?.chainId && - isDestChainNonEvm === isDestAddressNonEvm; - - // Only initialize in these specific cases: - // 1. Never initialized AND no destAddress set - // 2. destAddress doesn't match the current network type (user switched networks) - const shouldInitialize = - (!hasInitializedRecipient.current && !destAddress) || - !doesDestAddrMatchNetworkType; + // Initialize/reinitialize in these cases: + // 1. No destAddress is set (missing or cleared) + // 2. destAddress is not valid for the current destination chain (user switched networks) + // This handles switching between different non-EVM chains (e.g., BTC → SOL) + // Note: isDestAddressValidForDestChain returns false when destAddress is falsy, + const shouldInitialize = !isDestAddressValidForDestChain; if (shouldInitialize) { // Find an account from the currently selected account group that supports the destination network @@ -78,10 +82,10 @@ export const useRecipientInitialization = ( } }, [ destAddress, - destToken, destinationAccounts, handleSelectAccount, currentlySelectedAccount, hasInitializedRecipient, + isDestAddressValidForDestChain, ]); }; diff --git a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx index 1aefb93e35fe..6623c958de7b 100644 --- a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx +++ b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx @@ -8,9 +8,6 @@ import { TokenI } from '../../../Tokens/types'; // Mock dependencies jest.mock('../../../../../util/networks'); jest.mock('../../../../../util/networks/customNetworks'); -jest.mock( - '../../../Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping', -); jest.mock('../../../../Base/RemoteImage', () => 'RemoteImage'); import { diff --git a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx index 3332b59e053f..0d1a3fb4b37f 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx @@ -83,11 +83,7 @@ jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ }, })); -jest.mock('../Tokens/TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: () => [ - 'RootModalFlow', - { screen: 'TokenFilter' }, - ], +jest.mock('../Tokens/TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: () => [ 'RootModalFlow', { screen: 'TokensBottomSheet' }, diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index 2fb06392e91a..0baaae1957ea 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -87,17 +87,17 @@ export const usePerpsHomeData = ({ // REST API fills state - WebSocket snapshot only contains recent fills, // so we need to fetch complete history via REST API const [restFills, setRestFills] = useState([]); - const [isRestFillsLoading, setIsRestFillsLoading] = useState(true); - // Fetch historical fills via REST API on mount + // Fetch historical fills via REST API on mount (background, non-blocking) // This ensures we have complete fill history, not just WebSocket snapshot + // Note: We don't track loading state - WebSocket data displays immediately, + // REST fills merge silently in the background via mergedFills useEffect(() => { const fetchFills = async () => { try { const controller = Engine.context.PerpsController; const provider = controller?.getActiveProvider(); if (!provider) { - setIsRestFillsLoading(false); return; } @@ -106,8 +106,6 @@ export const usePerpsHomeData = ({ } catch (error) { // Log error but don't fail - WebSocket fills still work console.error('[usePerpsHomeData] Failed to fetch REST fills:', error); - } finally { - setIsRestFillsLoading(false); } }; fetchFills(); @@ -372,7 +370,9 @@ export const usePerpsHomeData = ({ positions: isPositionsLoading, orders: isOrdersLoading, markets: isMarketsLoading, - activity: isFillsLoading || isRestFillsLoading, + // Only wait for WebSocket fills (fast ~100ms), not REST fills (slow 3s+) + // REST fills merge in background via mergedFills without blocking initial render + activity: isFillsLoading, }, refresh, }; diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index f94776e682b4..473854cd5f0f 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -685,6 +685,8 @@ class PositionStreamChannel extends StreamChannel { // Specific channel for fills class FillStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; @@ -716,6 +718,42 @@ class FillStreamChannel extends StreamChannel { protected getClearedData(): OrderFill[] { return []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches fills data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + DevLogger.log('FillStreamChannel: Already pre-warmed'); + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + // Return cleanup function that clears internal state + return () => { + DevLogger.log('FillStreamChannel: Cleaning up prewarm subscription'); + this.cleanupPrewarm(); + }; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for account state diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d98663ca6abc..6dff1fbb1017 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -869,6 +869,7 @@ class PerpsConnectionManagerClass { const accountCleanup = streamManager.account.prewarm(); const marketDataCleanup = streamManager.marketData.prewarm(); const oiCapCleanup = streamManager.oiCaps.prewarm(); + const fillsCleanup = streamManager.fills.prewarm(); // Portfolio balance updates are now handled by usePerpsPortfolioBalance via usePerpsLiveAccount @@ -882,6 +883,7 @@ class PerpsConnectionManagerClass { accountCleanup, marketDataCleanup, oiCapCleanup, + fillsCleanup, priceCleanup, ); diff --git a/app/components/UI/Ramp/Deposit/sdk/index.test.tsx b/app/components/UI/Ramp/Deposit/sdk/index.test.tsx index 9df141875c98..67ec01e2735b 100644 --- a/app/components/UI/Ramp/Deposit/sdk/index.test.tsx +++ b/app/components/UI/Ramp/Deposit/sdk/index.test.tsx @@ -6,6 +6,7 @@ import { DepositSDKContext, DepositSDKProvider, useDepositSDK, + DEPOSIT_ENVIRONMENT, } from '.'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { @@ -15,11 +16,7 @@ import { } from '../testUtils'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { - NativeRampsSdk, - SdkEnvironment, - Context, -} from '@consensys/native-ramps-sdk'; +import { NativeRampsSdk, Context } from '@consensys/native-ramps-sdk'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ @@ -157,8 +154,9 @@ describe('Deposit SDK Context', () => { { apiKey: 'test-provider-api-key', context: Context.MobileIOS, + locale: 'en', }, - SdkEnvironment.Staging, + DEPOSIT_ENVIRONMENT, ); }); }); diff --git a/app/components/UI/Ramp/Deposit/sdk/index.tsx b/app/components/UI/Ramp/Deposit/sdk/index.tsx index 8a04728f2363..742d5968fb16 100644 --- a/app/components/UI/Ramp/Deposit/sdk/index.tsx +++ b/app/components/UI/Ramp/Deposit/sdk/index.tsx @@ -33,7 +33,7 @@ import { setFiatOrdersPaymentMethodDeposit, } from '../../../../../reducers/fiatOrders'; import Logger from '../../../../../util/Logger'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { I18nEvents, strings } from '../../../../../../locales/i18n'; import useRampAccountAddress from '../../hooks/useRampAccountAddress'; import { DepositNavigationParams } from '../types'; @@ -73,10 +73,15 @@ export const DEPOSIT_ENVIRONMENT = environment; export const DepositSDKNoAuth = new NativeRampsSdk( { context, + locale: I18n.locale, }, environment, ); +I18nEvents.addListener('localeChanged', (locale) => { + DepositSDKNoAuth.setLocale(locale); +}); + export const DepositSDKContext = createContext( undefined, ); @@ -151,6 +156,7 @@ export const DepositSDKProvider = ({ { apiKey: providerApiKey, context, + locale: I18n.locale, }, environment, ); @@ -161,6 +167,19 @@ export const DepositSDKProvider = ({ } }, [providerApiKey]); + // Listen for locale changes and update SDK locale + useEffect(() => { + if (!sdk) return; + + const handleLocaleChange = (locale: string) => { + sdk.setLocale(locale); + }; + I18nEvents.addListener('localeChanged', handleLocaleChange); + return () => { + I18nEvents.removeListener('localeChanged', handleLocaleChange); + }; + }, [sdk]); + useEffect(() => { if (sdk && authToken) { sdk.setAccessToken(authToken); @@ -216,7 +235,7 @@ export const DepositSDKProvider = ({ ? await sdk.logout() : await sdk .logout() - .catch((error) => + .catch((error: Error) => Logger.error( error as Error, 'SDK logout failed but invalidation was not required. Error:', diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx index fcdc47f80fb4..39be956cb7e1 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx @@ -50,7 +50,7 @@ const mockSelectAdditionalNetworksBlacklistFeatureFlag = jest.mock('../../../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const mockStrings: Record = { - 'rewards.ways_to_earn.supported_networks': 'Supported Networks', + 'rewards.ways_to_earn.supported_networks': 'Supported networks', }; return mockStrings[key] || key; }), @@ -139,7 +139,7 @@ describe('SwapSupportedNetworksSection', () => { const { getByText } = render(); // Assert - expect(getByText('Supported Networks')).toBeOnTheScreen(); + expect(getByText('Supported networks')).toBeOnTheScreen(); }); it('renders supported networks', () => { diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index 98a556a00b87..0a997e37af54 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -11,6 +11,7 @@ import { selectRewardsCardSpendFeatureFlags, selectRewardsMusdDepositEnabledFlag, } from '../../../../../../../selectors/featureFlagController/rewards'; +import { selectMusdHoldingEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; import { MetaMetricsEvents } from '../../../../../../hooks/useMetrics'; import { RewardsMetricsButtons } from '../../../../utils'; @@ -80,20 +81,13 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -// Mock useFeatureFlag hook -jest.mock('../../../../../../../components/hooks/useFeatureFlag', () => ({ - useFeatureFlag: jest.fn((key: string) => { - if (key === 'rewardsEnableMusdHolding') { - return mockIsMusdHoldingEnabled; - } - return false; +// Mock selectMusdHoldingEnabledFlag selector +jest.mock( + '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled', + () => ({ + selectMusdHoldingEnabledFlag: jest.fn(), }), - FeatureFlagNames: { - rewardsEnabled: 'rewardsEnabled', - otaUpdatesEnabled: 'otaUpdatesEnabled', - rewardsEnableMusdHolding: 'rewardsEnableMusdHolding', - }, -})); +); // Mock useMetrics hook jest.mock('../../../../../../hooks/useMetrics', () => ({ @@ -177,7 +171,7 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ 'rewards.ways_to_earn.card.sheet.points': '1 point per $1 spent', 'rewards.ways_to_earn.card.sheet.description': 'Earn points every time you use your MetaMask Card for purchases, plus 1% cash back (3% for Metal cardholders).', - 'rewards.ways_to_earn.card.sheet.cta_label': 'Manage Card', + 'rewards.ways_to_earn.card.sheet.cta_label': 'Manage card', // Deposit MUSD strings 'rewards.ways_to_earn.deposit_musd.title': 'Deposit mUSD', 'rewards.ways_to_earn.deposit_musd.description': @@ -209,7 +203,7 @@ jest.mock('./SwapSupportedNetworksSection', () => ({ return ReactActual.createElement( Text, { testID: 'swap-supported-networks' }, - 'Supported Networks', + 'Supported networks', ); }, })); @@ -283,6 +277,9 @@ describe('WaysToEarn', () => { if (selector === selectRewardsMusdDepositEnabledFlag) { return mockIsMusdDepositEnabled; } + if (selector === selectMusdHoldingEnabledFlag) { + return mockIsMusdHoldingEnabled; + } return undefined; }); @@ -1277,7 +1274,7 @@ describe('WaysToEarn', () => { { type: WayToEarnType.CARD, buttonText: 'MetaMask Card', - expectedCTALabel: 'Manage Card', + expectedCTALabel: 'Manage card', enableFlag: () => { mockIsCardSpendEnabled = true; }, diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index 5c064f9cfa13..0a54051fbcb3 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -33,11 +33,8 @@ import { selectRewardsCardSpendFeatureFlags, selectRewardsMusdDepositEnabledFlag, } from '../../../../../../../selectors/featureFlagController/rewards'; +import { selectMusdHoldingEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; -import { - useFeatureFlag, - FeatureFlagNames, -} from '../../../../../../hooks/useFeatureFlag'; import { PredictEventValues } from '../../../../../Predict/constants/eventNames'; import { MetaMetricsEvents, @@ -263,9 +260,7 @@ export const WaysToEarn = () => { const isCardSpendEnabled = useSelector(selectRewardsCardSpendFeatureFlags); const isPredictEnabled = useSelector(selectPredictEnabledFlag); const isMusdDepositEnabled = useSelector(selectRewardsMusdDepositEnabledFlag); - const isMusdHoldingEnabled = useFeatureFlag( - FeatureFlagNames.rewardsEnableMusdHolding, - ); + const isMusdHoldingEnabled = useSelector(selectMusdHoldingEnabledFlag); const { trackEvent, createEventBuilder } = useMetrics(); // Use the swap/bridge navigation hook diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index 4add77989e06..eb4b45e97365 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, { useCallback } from 'react'; -import { Alert, TouchableOpacity } from 'react-native'; +import { Alert, StyleSheet, TouchableOpacity } from 'react-native'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; import { strings } from '../../../../../../locales/i18n'; @@ -19,7 +19,6 @@ import { selectNetworkConfigurationByChainId, } from '../../../../../selectors/networkController'; import { getDecimalChainId } from '../../../../../util/networks'; -import { useTheme } from '../../../../../util/theme'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; @@ -28,7 +27,6 @@ import { selectPooledStakingEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; -import createStyles from '../../../Tokens/styles'; import { BrowserTab, TokenI } from '../../../Tokens/types'; import { EVENT_LOCATIONS } from '../../constants/events'; import useStakingChain from '../../hooks/useStakingChain'; @@ -46,14 +44,21 @@ import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import Logger from '../../../../../util/Logger'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; +const styles = StyleSheet.create({ + stakeButton: { + flexDirection: 'row', + }, + dot: { + marginLeft: 2, + marginRight: 2, + }, +}); 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); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx deleted file mode 100644 index 92ef10f79df5..000000000000 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; -import { PortfolioBalance } from '.'; -import Engine from '../../../../../core/Engine'; - -const { PreferencesController } = Engine.context; - -// Mock the useMultichainBalances hook -const mockSelectedAccountMultichainBalance = { - displayBalance: '$123.45', - totalFiatBalance: '123.45', - shouldShowAggregatedPercentage: true, - tokenFiatBalancesCrossChains: [], -}; - -jest.mock('../../../../hooks/useMultichainBalances', () => ({ - useSelectedAccountMultichainBalances: () => ({ - selectedAccountMultichainBalance: mockSelectedAccountMultichainBalance, - }), -})); - -jest.mock('../../../../../core/Engine', () => ({ - getTotalEvmFiatAccountBalance: jest.fn(), - context: { - TokensController: { - ignoreTokens: jest.fn(() => Promise.resolve()), - }, - PreferencesController: { - setPrivacyMode: jest.fn(), - }, - NetworkController: { - getNetworkClientById: () => ({ - configuration: { - chainId: '0x1', - rpcUrl: 'https://mainnet.infura.io/v3', - ticker: 'ETH', - type: 'custom', - }, - }), - state: { - selectedNetworkClientId: 'mainnet', - }, - }, - }, -})); - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: { - '0x1': { - blockExplorerUrls: [], - chainId: '0x1', - defaultRpcEndpointIndex: 1, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'mainnet', - type: 'infura', - url: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }, - { - name: 'public', - networkClientId: 'ea57f659-c004-4902-bfca-0c9688a43872', - type: 'custom', - url: 'https://mainnet-rpc.publicnode.com', - }, - ], - }, - }, - }, - TokensController: { - tokens: [ - { - name: 'Ethereum', - symbol: 'ETH', - address: '0x0', - decimals: 18, - isETH: true, - - balanceFiat: '< $0.01', - iconUrl: '', - }, - { - name: 'Bat', - symbol: 'BAT', - address: '0x01', - decimals: 18, - balanceFiat: '$0', - iconUrl: '', - }, - { - name: 'Link', - symbol: 'LINK', - address: '0x02', - decimals: 18, - balanceFiat: '$0', - iconUrl: '', - }, - ], - }, - TokenRatesController: { - marketData: { - '0x1': { - '0x0': { price: 0.005 }, - '0x01': { price: 0.005 }, - '0x02': { price: 0.005 }, - }, - }, - }, - CurrencyRateController: { - currentCurrency: 'USD', - currencyRates: { - ETH: { - conversionRate: 1, - }, - }, - }, - TokenBalancesController: { - tokenBalances: {}, - }, - MultichainNetworkController: { - isEvmSelected: true, - }, - }, - }, - settings: { - primaryCurrency: 'usd', - hideZeroBalanceTokens: true, - }, - security: { - dataCollectionForMarketing: true, - }, -}; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const renderPortfolioBalance = (state: any = {}) => - renderWithProvider(, { state }); - -describe('PortfolioBalance', () => { - it('fiat balance must be defined', () => { - const { getByTestId } = renderPortfolioBalance(initialState); - expect( - getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), - ).toBeDefined(); - }); - - it('renders sensitive text when privacy mode is off', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: false, - }, - }, - }, - }); - const sensitiveText = getByTestId( - WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT, - ); - expect(sensitiveText.props.isHidden).toBeFalsy(); - }); - - it('hides sensitive text when privacy mode is on', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: true, - }, - }, - }, - }); - const sensitiveText = getByTestId( - WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT, - ); - expect(sensitiveText.props.children).toEqual('••••••••••••'); - }); - - it('toggles privacy mode when balance container is pressed', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: false, - }, - }, - }, - }); - - const balanceContainer = getByTestId('balance-container'); - fireEvent.press(balanceContainer); - - expect(PreferencesController.setPrivacyMode).toHaveBeenCalledWith(true); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx deleted file mode 100644 index 0794f0abf6fc..000000000000 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useCallback } from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../../util/theme'; -import Engine from '../../../../../core/Engine'; -import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; -import createStyles from '../../styles'; -import { TextVariant } from '../../../../../component-library/components/Texts/Text'; -import SensitiveText, { - SensitiveTextLength, -} from '../../../../../component-library/components/Texts/SensitiveText'; -import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; -import AggregatedPercentageCrossChains from '../../../../../component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentageCrossChains'; -import { useSelectedAccountMultichainBalances } from '../../../../hooks/useMultichainBalances'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; -import NonEvmAggregatedPercentage from '../../../../../component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage'; -import { selectIsEvmNetworkSelected } from '../../../../../selectors/multichainNetworkController'; - -export const PortfolioBalance = React.memo(() => { - const { PreferencesController } = Engine.context; - const { colors } = useTheme(); - const styles = createStyles(colors); - const privacyMode = useSelector(selectPrivacyMode); - - const { selectedAccountMultichainBalance } = - useSelectedAccountMultichainBalances(); - const isEvmSelected = useSelector(selectIsEvmNetworkSelected); - - const renderAggregatedPercentage = () => { - if ( - !selectedAccountMultichainBalance || - !selectedAccountMultichainBalance?.shouldShowAggregatedPercentage || - selectedAccountMultichainBalance?.totalFiatBalance === undefined - ) { - return null; - } - - if (!isEvmSelected) { - return ; - } - - return ( - - ); - }; - - const toggleIsBalanceAndAssetsHidden = useCallback( - (value: boolean) => { - PreferencesController.setPrivacyMode(value); - }, - [PreferencesController], - ); - - return ( - - - {selectedAccountMultichainBalance?.displayBalance ? ( - toggleIsBalanceAndAssetsHidden(!privacyMode)} - testID="balance-container" - > - - - {selectedAccountMultichainBalance?.displayBalance} - - - - {renderAggregatedPercentage()} - - ) : ( - - - - - )} - - - ); -}); diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx similarity index 73% rename from app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx rename to app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx index ba4613ced323..10b8044599d2 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx @@ -1,10 +1,9 @@ import React from 'react'; import Modal from 'react-native-modal'; import { useTheme } from '../../../../../util/theme'; -import createStyles from '../../styles'; import Box from '../../../Ramp/Aggregator/components/Box'; -import { View } from 'react-native'; -import SheetHeader from '../../../../../../app/component-library/components/Sheet/SheetHeader'; +import { StyleSheet, View } from 'react-native'; +import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import { strings } from '../../../../../../locales/i18n'; import Text from '../../../../../component-library/components/Texts/Text'; import Button, { @@ -18,7 +17,39 @@ import { import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../../constants/navigation/Routes'; +import { Colors } from '../../../../../util/theme/models'; +const createStyles = (colors: Colors) => + StyleSheet.create({ + bottomModal: { + justifyContent: 'flex-end', + margin: 0, + }, + box: { + backgroundColor: colors.background.default, + paddingHorizontal: 8, + paddingBottom: 20, + borderWidth: 0, + padding: 0, + }, + boxContent: { + backgroundColor: colors.background.default, + paddingBottom: 21, + paddingTop: 0, + borderWidth: 0, + }, + editNetworkButton: { + width: '100%', + }, + notch: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: colors.border.muted, + alignSelf: 'center', + marginTop: 4, + }, + }); interface ScamWarningModalProps { showScamWarningModal: boolean; setShowScamWarningModal: (arg: boolean) => void; @@ -30,12 +61,11 @@ export const ScamWarningModal = ({ }: ScamWarningModalProps) => { const navigation = useNavigation(); const { colors } = useTheme(); + const styles = createStyles(colors); const ticker = useSelector(selectEvmTicker); const { rpcUrl } = useSelector(selectProviderConfig); - const styles = createStyles(colors); - const goToNetworkEdit = () => { navigation.navigate(Routes.ADD_NETWORK, { network: rpcUrl, diff --git a/app/components/UI/Tokens/TokenList/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx similarity index 99% rename from app/components/UI/Tokens/TokenList/index.test.tsx rename to app/components/UI/Tokens/TokenList/TokenList.test.tsx index 08ca55037fa9..78420cb43762 100644 --- a/app/components/UI/Tokens/TokenList/index.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; -import { TokenList } from './index'; +import { TokenList } from './TokenList'; import { useNavigation } from '@react-navigation/native'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useMetrics } from '../../../hooks/useMetrics'; @@ -51,7 +51,7 @@ jest.mock('../../../../selectors/featureFlagController/homepage', () => ({ })); // Mock child components -jest.mock('./TokenListItem', () => ({ +jest.mock('./TokenListItem/TokenListItem', () => ({ TokenListItem: ({ assetKey }: { assetKey: { address: string } }) => { const React = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx similarity index 90% rename from app/components/UI/Tokens/TokenList/index.tsx rename to app/components/UI/Tokens/TokenList/TokenList.tsx index 9489f823c338..cbd3e709765a 100644 --- a/app/components/UI/Tokens/TokenList/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -10,11 +10,10 @@ import { import { TokenI } from '../types'; import { strings } from '../../../../../locales/i18n'; -import { TokenListItem, TokenListItemBip44 } from './TokenListItem'; +import { TokenListItem } from './TokenListItem/TokenListItem'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; import { selectHomepageRedesignV1Enabled } from '../../../../selectors/featureFlagController/homepage'; import { Box, @@ -61,14 +60,6 @@ const TokenListComponent = ({ selectHomepageRedesignV1Enabled, ); - // BIP44 MAINTENANCE: Once stable, only use TokenListItemBip44 - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - const TokenListItemComponent = isMultichainAccountsState2Enabled - ? TokenListItemBip44 - : TokenListItem; - const listRef = useRef>(null); const navigation = useNavigation(); @@ -101,7 +92,7 @@ const TokenListComponent = ({ const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( - {displayTokenKeys.map((item, index) => ( - = { - [NETWORK_CHAIN_ID.FLARE_MAINNET]: FlareMainnetImg, - [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: SongbirdImg, - [NETWORK_CHAIN_ID.APECHAIN_TESTNET]: ApeNetworkImg, - [NETWORK_CHAIN_ID.APECHAIN_MAINNET]: ApeNetworkImg, - [NETWORK_CHAIN_ID.GRAVITY_ALPHA_MAINNET]: GravityImg, - [NETWORK_CHAIN_ID.KAIA_MAINNET]: KaiaImg, - [NETWORK_CHAIN_ID.KAIA_KAIROS_TESTNET]: KaiaImg, - [NETWORK_CHAIN_ID.SONEIUM_MAINNET]: ethImg, - [NETWORK_CHAIN_ID.SONEIUM_MINATO_TESTNET]: ethImg, - [NETWORK_CHAIN_ID.XRPLEVM_TESTNET]: XrpLevmImg, - [NETWORK_CHAIN_ID.SOPHON]: SophonImg, - [NETWORK_CHAIN_ID.SOPHON_TESTNET]: SophonTestnetImg, - [NETWORK_CHAIN_ID.MEGAETH_MAINNET]: ethImg, - [NETWORK_CHAIN_ID.MEGAETH_TESTNET]: MegaethTestnetImg, - [NETWORK_CHAIN_ID.LUKSO]: LuksoImg, - [NETWORK_CHAIN_ID.INJECTIVE]: InjectiveImg, - [NETWORK_CHAIN_ID.PLASMA]: PlasmaImg, - [NETWORK_CHAIN_ID.HYPE]: HypeImg, -}; diff --git a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx similarity index 79% rename from app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx index 4760197993ad..cacdf2fa13f3 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; -import { TokenI } from '../../types'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { ScamWarningIcon } from '.'; -import ButtonIcon from '../../../../../component-library/components/Buttons/ButtonIcon'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import useIsOriginalNativeTokenSymbol from '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; +import { TokenI } from '../../../types'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { ScamWarningIcon } from './ScamWarningIcon'; +import ButtonIcon from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -13,7 +13,7 @@ jest.mock('react-redux', () => ({ })); jest.mock( - '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol', + '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol', () => ({ __esModule: true, default: jest.fn(), diff --git a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx similarity index 69% rename from app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx index f709d8a3185f..642d3c6ab811 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { TokenI } from '../../types'; -import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; +import { TokenI } from '../../../types'; +import useIsOriginalNativeTokenSymbol from '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { useSelector } from 'react-redux'; -import { selectProviderConfig } from '../../../../../selectors/networkController'; +import { selectProviderConfig } from '../../../../../../selectors/networkController'; import ButtonIcon, { ButtonIconSizes, -} from '../../../../../../app/component-library/components/Buttons/ButtonIcon'; +} from '../../../../../../component-library/components/Buttons/ButtonIcon'; import { IconColor, IconName, -} from '../../../../../component-library/components/Icons/Icon'; +} from '../../../../../../component-library/components/Icons/Icon'; interface ScamWarningIconProps { asset: TokenI & { chainId: string }; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx similarity index 95% rename from app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index cc139cdec24f..2b464ee4dbb8 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1,11 +1,8 @@ import { BtcAccountType } from '@metamask/keyring-api'; import React from 'react'; import { useSelector } from 'react-redux'; -import { - ACCOUNT_TYPE_LABEL_TEST_ID, - TokenListItemBip44, -} from './TokenListItemBip44'; -import { FlashListAssetKey } from '..'; +import { ACCOUNT_TYPE_LABEL_TEST_ID, TokenListItem } from './TokenListItem'; +import { FlashListAssetKey } from '../TokenList'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { isTestNet } from '../../../../../util/networks'; import { formatWithThreshold } from '../../../../../util/assets'; @@ -156,12 +153,6 @@ jest.mock('../../../../../constants/popular-networks', () => ({ POPULAR_NETWORK_CHAIN_IDS: new Set(['0x1', '0xe708']), })); -jest.mock('./CustomNetworkNativeImgMapping', () => ({ - CustomNetworkNativeImgMapping: { - '0x89': { uri: 'polygon-native.png' }, - }, -})); - // Mock useSelector to return controlled data jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -211,7 +202,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { const selectorString = selector.toString(); - // TokenListItemBip44 selectors + // TokenListItem selectors if (selectorString.includes('selectAsset')) { return asset; } @@ -265,7 +256,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }; const { getByText } = renderWithProvider( - { }; const { getByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - + StyleSheet.create({ + balances: { + flex: 1, + justifyContent: 'center', + marginLeft: 20, + }, + balanceFiat: { + color: colors.text.alternative, + ...fontStyles.normal, + textTransform: 'uppercase', + }, + badge: { + marginTop: 8, + }, + assetName: { + flexDirection: 'row', + gap: 8, + }, + percentageChange: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + }, + }); + interface TokenListItemProps { assetKey: FlashListAssetKey; showRemoveMenu: (arg: TokenI) => void; @@ -53,7 +80,7 @@ interface TokenListItemProps { isFullView?: boolean; } -export const TokenListItemBip44 = React.memo( +export const TokenListItem = React.memo( ({ assetKey, showRemoveMenu, @@ -245,4 +272,4 @@ export const TokenListItemBip44 = React.memo( }, ); -TokenListItemBip44.displayName = 'TokenListItemBip44'; +TokenListItem.displayName = 'TokenListItem'; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx deleted file mode 100644 index 6b28b021feac..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx +++ /dev/null @@ -1,2372 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { Provider, useSelector } from 'react-redux'; -import { NavigationContainer } from '@react-navigation/native'; -import { configureStore } from '@reduxjs/toolkit'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; -import { TOKEN_RATE_UNDEFINED } from '../../constants'; -import { TokenListItem } from './index'; -import { FlashListAssetKey } from '..'; - -// Mock dependencies -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: jest.fn(), - }), -})); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ colors: {} }), -})); - -jest.mock('../../../../hooks/useMetrics', () => ({ - useMetrics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - build: jest.fn(), - addProperties: jest.fn(() => ({ build: jest.fn() })), - })), - }), -})); - -jest.mock('../../hooks/useTokenPricePercentageChange', () => ({ - useTokenPricePercentageChange: jest.fn(), -})); - -jest.mock('../../../Earn/hooks/useEarnTokens', () => ({ - __esModule: true, - default: () => ({ getEarnToken: jest.fn() }), -})); - -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: () => ({ - initiateConversion: jest.fn(), - error: null, - }), -})); - -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: jest.fn(() => ({ - isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), - tokens: [], - })), -})); - -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: () => true, // Enable to show Earn button - selectStablecoinLendingEnabledFlag: () => false, - selectIsMusdConversionFlowEnabledFlag: () => false, - selectMusdConversionPaymentTokensAllowlist: () => ({}), -})); - -jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({ - deriveBalanceFromAssetMarketDetails: jest.fn(() => ({ - balanceFiat: '$100.00', - balanceValueFormatted: '1.23 ETH', - })), -})); - -jest.mock('../../../../../util/assets', () => ({ - formatWithThreshold: jest.fn((value) => `${value} TEST`), -})); - -jest.mock('../../../../../util/networks', () => { - const actual = jest.requireActual('../../../../../util/networks'); - - return { - ...actual, - getDefaultNetworkByChainId: jest.fn(), - getTestNetImageByChainId: jest.fn(() => 'testnet.png'), - isTestNet: jest.fn(), - }; -}); - -jest.mock('../../../../../util/networks/customNetworks', () => { - const actual = jest.requireActual( - '../../../../../util/networks/customNetworks', - ); - - return { - ...actual, - CustomNetworkImgMapping: {}, - PopularList: [], - UnpopularNetworkList: [], - getNonEvmNetworkImageSourceByChainId: jest.fn(), - }; -}); - -jest.mock('../../../../../constants/network', () => ({ - NETWORKS_CHAIN_ID: { - MAINNET: '0x1', - OPTIMISM: '0xa', - BSC: '0x38', - POLYGON: '0x89', - FANTOM: '0xfa', - BASE: '0x2105', - ARBITRUM: '0xa4b1', - AVAXCCHAIN: '0xa86a', - CELO: '0xa4ec', - HARMONY: '0x63564c40', - SEPOLIA: '0xaa36a7', - LINEA_GOERLI: '0xe704', - LINEA_SEPOLIA: '0xe705', - GOERLI: '0x5', - LINEA_MAINNET: '0xe708', - ZKSYNC_ERA: '0x144', - LOCALHOST: '0x539', - ARBITRUM_GOERLI: '0x66eed', - OPTIMISM_GOERLI: '0x1a4', - MUMBAI: '0x13881', - OPBNB: '0xcc', - SCROLL: '0x82750', - BERACHAIN: '0x138d6', - METACHAIN_ONE: '0x1b6a6', - MEGAETH_TESTNET: '0x18c6', - SEI: '0x531', - MONAD_TESTNET: '0x279f', - }, - NETWORK_CHAIN_ID: { - FLARE_MAINNET: '0x13', - SONGBIRD_TESTNET: '0x14', - APECHAIN_TESTNET: '0x15', - APECHAIN_MAINNET: '0x16', - }, -})); - -jest.mock('../../../../../constants/popular-networks', () => ({ - POPULAR_NETWORK_CHAIN_IDS: new Set(['0x1', '0xe708']), -})); - -jest.mock('./CustomNetworkNativeImgMapping', () => ({ - CustomNetworkNativeImgMapping: { - '0x89': 'polygon-native.png', - '0xa86a': 'avalanche-native.png', - }, -})); - -// Mock all selectors -const mockStore = configureStore({ - reducer: { - root: (state = {}) => state, - }, - preloadedState: { - root: {}, - }, -}); - -const MockProvider = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -// Mock useSelector to return controlled data -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -describe('TokenListItem - Core Logic', () => { - describe('percentage availability check', () => { - const hasPercentageChange = ( - _chainId: string, - showPercentageChange: boolean, - pricePercentChange1d: number | null | undefined, - isTestNet: boolean = false, - ): boolean => - !isTestNet && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - describe('when on mainnet', () => { - it('returns true for valid finite percentage', () => { - // Arrange - const validPercentage = 5.67; - - // Act - const result = hasPercentageChange('0x1', true, validPercentage, false); - - // Assert - expect(result).toBe(true); - }); - - it('returns false for null percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, null, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for undefined percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, undefined, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false when showPercentageChange is disabled', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', false, 5.67, false); - - // Assert - expect(result).toBe(false); - }); - }); - - describe('when on testnet', () => { - it('returns false even with valid percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, 5.67, true); - - // Assert - expect(result).toBe(false); - }); - }); - - describe('critical edge cases - prevents crash', () => { - it('returns false for Infinity to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, Infinity, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for negative Infinity to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, -Infinity, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for NaN to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, NaN, false); - - // Assert - expect(result).toBe(false); - }); - }); - }); - - describe('percentage color logic', () => { - const getPercentageColor = ( - pricePercentChange1d: number | null, - hasPercentageChange: boolean, - ): TextColor => { - if (!hasPercentageChange) return TextColor.Alternative; - if (pricePercentChange1d === 0) return TextColor.Alternative; - if (pricePercentChange1d && pricePercentChange1d > 0) - return TextColor.Success; - return TextColor.Error; - }; - - describe('when percentage change is available', () => { - it('returns success color for positive percentage change', () => { - // Arrange - const positivePercentage = 5.67; - - // Act - const result = getPercentageColor(positivePercentage, true); - - // Assert - expect(result).toBe(TextColor.Success); - }); - - it('returns error color for negative percentage change', () => { - // Arrange - const negativePercentage = -3.25; - - // Act - const result = getPercentageColor(negativePercentage, true); - - // Assert - expect(result).toBe(TextColor.Error); - }); - - it('returns alternative color for zero percentage change', () => { - // Arrange - const zeroPercentage = 0; - - // Act - const result = getPercentageColor(zeroPercentage, true); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - - it('returns alternative color for very small positive change', () => { - // Arrange - const smallPositive = 0.01; - - // Act - const result = getPercentageColor(smallPositive, true); - - // Assert - expect(result).toBe(TextColor.Success); - }); - - it('returns error color for very small negative change', () => { - // Arrange - const smallNegative = -0.01; - - // Act - const result = getPercentageColor(smallNegative, true); - - // Assert - expect(result).toBe(TextColor.Error); - }); - }); - - describe('when percentage change is not available', () => { - it('returns alternative color when percentage not available', () => { - // Arrange & Act - const result = getPercentageColor(null, false); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - - it('returns alternative color even with valid percentage when disabled', () => { - // Arrange & Act - const result = getPercentageColor(5.67, false); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - }); - }); - - describe('percentage text formatting', () => { - const formatPercentageText = ( - value: number | null | undefined, - hasChange: boolean, - ): string | undefined => { - if (!hasChange || value === null || value === undefined) return undefined; - if (!Number.isFinite(value)) return undefined; // Critical safety check - return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`; - }; - - describe('valid formatting cases', () => { - it('formats positive percentages with plus sign', () => { - // Arrange - const positiveValue = 12.345; - - // Act - const result = formatPercentageText(positiveValue, true); - - // Assert - expect(result).toBe('+12.35%'); - }); - - it('formats negative percentages correctly', () => { - // Arrange - const negativeValue = -8.91; - - // Act - const result = formatPercentageText(negativeValue, true); - - // Assert - expect(result).toBe('-8.91%'); - }); - - it('formats zero percentage with plus sign', () => { - // Arrange - const zeroValue = 0; - - // Act - const result = formatPercentageText(zeroValue, true); - - // Assert - expect(result).toBe('+0.00%'); - }); - - it('formats large positive percentages correctly', () => { - // Arrange - const largePositive = 999.999; - - // Act - const result = formatPercentageText(largePositive, true); - - // Assert - expect(result).toBe('+1000.00%'); - }); - - it('formats large negative percentages correctly', () => { - // Arrange - const largeNegative = -99.99; - - // Act - const result = formatPercentageText(largeNegative, true); - - // Assert - expect(result).toBe('-99.99%'); - }); - }); - - describe('edge cases that return undefined', () => { - it('returns undefined when no percentage available', () => { - // Arrange & Act - const result = formatPercentageText(null, false); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for null value', () => { - // Arrange & Act - const result = formatPercentageText(null, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for undefined value', () => { - // Arrange & Act - const result = formatPercentageText(undefined, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined when hasChange is false', () => { - // Arrange & Act - const result = formatPercentageText(5.67, false); - - // Assert - expect(result).toBeUndefined(); - }); - }); - - describe('critical safety checks - prevents application crashes', () => { - it('returns undefined for Infinity instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(Infinity, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for negative Infinity instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(-Infinity, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for NaN instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(NaN, true); - - // Assert - expect(result).toBeUndefined(); - }); - - // Test the test: Verify that without safety check, toFixed produces invalid results - it('demonstrates why safety check is needed', () => { - // Arrange - const unsafeFormat = (value: number) => value.toFixed(2); - - // Act & Assert - toFixed doesn't crash but produces invalid percentage strings - expect(unsafeFormat(Infinity)).toBe('Infinity'); - expect(unsafeFormat(NaN)).toBe('NaN'); - expect(unsafeFormat(-Infinity)).toBe('-Infinity'); - - // These would result in invalid percentage text like "Infinity%" or "NaN%" - const unsafePercentage = (value: number) => - `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`; - expect(unsafePercentage(Infinity)).toBe('+Infinity%'); - expect(unsafePercentage(NaN)).toBe('NaN%'); // NaN >= 0 is false, so no + prefix - expect(unsafePercentage(-Infinity)).toBe('-Infinity%'); - }); - }); - }); - - describe('percentage display priority logic', () => { - const getDisplayPriority = ( - hasPercentageChange: boolean, - hasBalanceError: boolean, - isRateUndefined: boolean, - ): 'percentage' | 'error' | 'rate_error' => { - if (hasBalanceError) return 'error'; - if (isRateUndefined) return 'rate_error'; - if (hasPercentageChange) return 'percentage'; - return 'percentage'; // fallback - }; - - it('prioritizes balance error over percentage', () => { - // Arrange & Act - const result = getDisplayPriority(true, true, false); - - // Assert - expect(result).toBe('error'); - }); - - it('prioritizes rate error over percentage when no balance error', () => { - // Arrange & Act - const result = getDisplayPriority(true, false, true); - - // Assert - expect(result).toBe('rate_error'); - }); - - it('shows percentage when no errors', () => { - // Arrange & Act - const result = getDisplayPriority(true, false, false); - - // Assert - expect(result).toBe('percentage'); - }); - - it('shows percentage fallback when no percentage change available', () => { - // Arrange & Act - const result = getDisplayPriority(false, false, false); - - // Assert - expect(result).toBe('percentage'); - }); - }); - - describe('parameterized edge case testing', () => { - describe.each([ - { - value: 0.001, - expected: '+0.00%', - description: 'very small positive rounds to zero', - }, - { - value: -0.001, - expected: '-0.00%', - description: 'very small negative rounds to zero', - }, - { value: 0.005, expected: '+0.01%', description: 'rounding up at 0.5' }, - { - value: -0.005, - expected: '-0.01%', - description: 'rounding down at -0.5', - }, - { - value: 100, - expected: '+100.00%', - description: 'exact hundred percent', - }, - { - value: -100, - expected: '-100.00%', - description: 'exact negative hundred percent', - }, - ])( - 'percentage formatting edge cases', - ({ value, expected, description }) => { - it(`correctly formats ${description}`, () => { - // Arrange - const formatPercentageText = (val: number) => - `${val >= 0 ? '+' : ''}${val.toFixed(2)}%`; - - // Act - const result = formatPercentageText(value); - - // Assert - expect(result).toBe(expected); - }); - }, - ); - }); -}); - -describe('TokenListItem - Utility Logic Tests', () => { - describe('Balance Display Logic', () => { - const testBalanceDisplayLogic = ( - balanceFiat: string | undefined, - balanceValueFormatted: string | undefined, - hasBalanceError: boolean, - isTestNet: boolean, - showFiatOnTestnets: boolean, - ) => { - let mainBalance; - let secondaryBalance; - const shouldNotShowBalanceOnTestnets = isTestNet && !showFiatOnTestnets; - - // Mirror the logic from the component - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - mainBalance = undefined; - } else { - mainBalance = balanceFiat ?? 'Unable to find conversion rate'; - } - - if (hasBalanceError) { - mainBalance = 'ETH'; // Mock symbol - secondaryBalance = 'Unable to load'; - } - - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = balanceValueFormatted; - secondaryBalance = 'Unable to find conversion rate'; - } - - return { mainBalance, secondaryBalance }; - }; - - it('displays fiat balance when available on mainnet', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('$1000.00'); - }); - - it('hides balance on testnet when showFiatOnTestnets is false', () => { - const result = testBalanceDisplayLogic( - undefined, - '2.5 ETH', - false, - true, - false, - ); - expect(result.mainBalance).toBeUndefined(); - }); - - it('shows balance on testnet when showFiatOnTestnets is true', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - false, - true, - true, - ); - expect(result.mainBalance).toBe('$1000.00'); - }); - - it('shows error message when balance has error', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - true, - false, - false, - ); - expect(result.mainBalance).toBe('ETH'); - expect(result.secondaryBalance).toBe('Unable to load'); - }); - - it('shows token amount when rate is undefined', () => { - const result = testBalanceDisplayLogic( - TOKEN_RATE_UNDEFINED, - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('2.5 ETH'); - expect(result.secondaryBalance).toBe('Unable to find conversion rate'); - }); - - it('shows fallback message when no fiat available', () => { - const result = testBalanceDisplayLogic( - undefined, - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('Unable to find conversion rate'); - }); - }); - - describe('Network Badge Logic', () => { - const testNetworkBadgeLogic = (chainId: string) => { - // Simplified version of the networkBadgeSource logic - const testNetworkMapping: Record = { - '0x1': 'mainnet-image.png', - '0x5': 'goerli-image.png', - '0x89': 'polygon-image.png', - }; - - if (chainId.startsWith('0x5') || chainId.startsWith('0x4')) { - return 'testnet-image.png'; - } - - return testNetworkMapping[chainId] || 'default-image.png'; - }; - - it('returns mainnet image for Ethereum mainnet', () => { - expect(testNetworkBadgeLogic('0x1')).toBe('mainnet-image.png'); - }); - - it('returns testnet image for Goerli', () => { - expect(testNetworkBadgeLogic('0x5')).toBe('testnet-image.png'); - }); - - it('returns polygon image for Polygon', () => { - expect(testNetworkBadgeLogic('0x89')).toBe('polygon-image.png'); - }); - - it('returns default image for unknown network', () => { - expect(testNetworkBadgeLogic('0x999')).toBe('default-image.png'); - }); - }); - - describe('Asset Type Logic', () => { - const testAssetTypeLogic = (asset: { - isNative: boolean; - isETH: boolean; - }) => { - if (asset.isNative) { - return 'native'; - } - if (asset.isETH) { - return 'eth'; - } - return 'token'; - }; - - it('identifies native assets correctly', () => { - const nativeAsset = { isNative: true, isETH: false }; - expect(testAssetTypeLogic(nativeAsset)).toBe('native'); - }); - - it('identifies ETH assets correctly', () => { - const ethAsset = { isNative: false, isETH: true }; - expect(testAssetTypeLogic(ethAsset)).toBe('eth'); - }); - - it('identifies regular tokens correctly', () => { - const tokenAsset = { isNative: false, isETH: false }; - expect(testAssetTypeLogic(tokenAsset)).toBe('token'); - }); - - it('prioritizes native over ETH when both are true', () => { - const nativeEthAsset = { isNative: true, isETH: true }; - expect(testAssetTypeLogic(nativeEthAsset)).toBe('native'); - }); - }); - - describe('Long Press Logic', () => { - const testLongPressLogic = (asset: { isETH: boolean; isNative: boolean }) => - // Mirror the onLongPress logic from component - asset.isETH || asset.isNative ? null : 'showRemoveMenu'; - it('disables long press for ETH', () => { - const ethAsset = { isETH: true, isNative: false }; - expect(testLongPressLogic(ethAsset)).toBeNull(); - }); - - it('disables long press for native assets', () => { - const nativeAsset = { isETH: false, isNative: true }; - expect(testLongPressLogic(nativeAsset)).toBeNull(); - }); - - it('enables long press for regular tokens', () => { - const tokenAsset = { isETH: false, isNative: false }; - expect(testLongPressLogic(tokenAsset)).toBe('showRemoveMenu'); - }); - - it('disables long press when both ETH and native are true', () => { - const ethNativeAsset = { isETH: true, isNative: true }; - expect(testLongPressLogic(ethNativeAsset)).toBeNull(); - }); - }); -}); - -describe('TokenListItem - Advanced Component Logic', () => { - describe('Balance Calculation and Formatting', () => { - const testBalanceDerivation = ( - asset: { - address: string; - symbol: string; - balance?: string; - balanceFiat?: string; - } | null, - exchangeRates: Record, - tokenBalances: Record, - conversionRate: number, - _currentCurrency: string, - isEvmNetworkSelected: boolean, - ) => { - if (!isEvmNetworkSelected || !asset) { - return { - balanceFiat: asset?.balanceFiat - ? `$${asset.balanceFiat}` - : 'Loading...', - balanceValueFormatted: asset?.balance - ? `${asset.balance} ${asset.symbol}` - : 'Loading...', - }; - } - - // Simplified balance derivation logic - const rate = exchangeRates[asset.address]?.price || 0; - const balance = tokenBalances[asset.address] || '0'; - const balanceNum = parseFloat(balance); - const fiatValue = balanceNum * rate * conversionRate; - - return { - balanceFiat: fiatValue > 0 ? `$${fiatValue.toFixed(2)}` : '$0.00', - balanceValueFormatted: `${balanceNum} ${asset.symbol}`, - }; - }; - - it('calculates fiat balance correctly for EVM assets', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '100' }; - const exchangeRates = { '0x123': { price: 2.5 } }; - const tokenBalances = { '0x123': '100' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$250.00'); - expect(result.balanceValueFormatted).toBe('100 TEST'); - }); - - it('handles non-EVM assets with pre-calculated values', () => { - const asset = { - address: 'cosmos:asset', - symbol: 'ATOM', - balance: '50', - balanceFiat: '125.50', - }; - - const result = testBalanceDerivation(asset, {}, {}, 1.0, 'USD', false); - - expect(result.balanceFiat).toBe('$125.50'); - expect(result.balanceValueFormatted).toBe('50 ATOM'); - }); - - it('handles zero balance correctly', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '0' }; - const exchangeRates = { '0x123': { price: 2.5 } }; - const tokenBalances = { '0x123': '0' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$0.00'); - expect(result.balanceValueFormatted).toBe('0 TEST'); - }); - - it('handles missing exchange rate gracefully', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '100' }; - const exchangeRates = {}; // No rate available - const tokenBalances = { '0x123': '100' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$0.00'); - expect(result.balanceValueFormatted).toBe('100 TEST'); - }); - }); - - describe('Asset Selection Logic', () => { - const testAssetSelection = ( - isEvmNetworkSelected: boolean, - evmAsset: { chainId: string; symbol: string } | null, - nonEvmAsset: { chainId: string; symbol: string } | null, - ) => (isEvmNetworkSelected ? evmAsset : nonEvmAsset); - - it('selects EVM asset when EVM network is selected', () => { - const evmAsset = { chainId: '0x1', symbol: 'ETH' }; - const nonEvmAsset = { chainId: 'cosmos:hub', symbol: 'ATOM' }; - - const result = testAssetSelection(true, evmAsset, nonEvmAsset); - expect(result).toBe(evmAsset); - }); - - it('selects non-EVM asset when non-EVM network is selected', () => { - const evmAsset = { chainId: '0x1', symbol: 'ETH' }; - const nonEvmAsset = { chainId: 'cosmos:hub', symbol: 'ATOM' }; - - const result = testAssetSelection(false, evmAsset, nonEvmAsset); - expect(result).toBe(nonEvmAsset); - }); - - it('handles null assets gracefully', () => { - const result = testAssetSelection(true, null, null); - expect(result).toBeNull(); - }); - }); - - describe('Navigation and Analytics', () => { - const testNavigationLogic = ( - asset: { - chainId: string; - symbol: string; - address: string; - isStaked?: boolean; - nativeAsset?: { chainId: string; symbol: string; address: string }; - } | null, - trackEventFn: jest.Mock, - navigateFn: jest.Mock, - ) => { - // Mock the onItemPress logic - if (!asset) return; - - trackEventFn({ - category: 'TOKEN_DETAILS_OPENED', - properties: { - source: 'mobile-token-list', - chain_id: asset.chainId, - token_symbol: asset.symbol, - }, - }); - - if (asset.isStaked) { - navigateFn('Asset', asset.nativeAsset); - } else { - navigateFn('Asset', asset); - } - }; - - it('tracks event and navigates to regular asset', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - const asset = { - chainId: '0x1', - symbol: 'TOKEN', - address: '0x123', - isStaked: false, - }; - - testNavigationLogic(asset, trackEvent, navigate); - - expect(trackEvent).toHaveBeenCalledWith({ - category: 'TOKEN_DETAILS_OPENED', - properties: { - source: 'mobile-token-list', - chain_id: '0x1', - token_symbol: 'TOKEN', - }, - }); - expect(navigate).toHaveBeenCalledWith('Asset', asset); - }); - - it('navigates to native asset for staked tokens', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - const asset = { - chainId: '0x1', - symbol: 'stETH', - address: '0x456', - isStaked: true, - nativeAsset: { chainId: '0x1', symbol: 'ETH', address: '0x0' }, - }; - - testNavigationLogic(asset, trackEvent, navigate); - - expect(navigate).toHaveBeenCalledWith('Asset', asset.nativeAsset); - }); - - it('handles null asset gracefully', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - - testNavigationLogic(null, trackEvent, navigate); - - expect(trackEvent).not.toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - }); - }); - - describe('Testnet Balance Display Logic', () => { - const testTestnetLogic = ( - chainId: string, - showFiatOnTestnets: boolean, - balanceFiat: string | undefined, - ) => { - const isTestNet = chainId.startsWith('0x5') || chainId.startsWith('0x4'); - const shouldNotShowBalanceOnTestnets = isTestNet && !showFiatOnTestnets; - - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - return { mainBalance: undefined, shouldHide: true }; - } - - return { - mainBalance: balanceFiat ?? 'Unable to find conversion rate', - shouldHide: false, - }; - }; - - it('hides balance on testnet when showFiatOnTestnets is disabled', () => { - const result = testTestnetLogic('0x5', false, undefined); - expect(result.shouldHide).toBe(true); - expect(result.mainBalance).toBeUndefined(); - }); - - it('shows balance on testnet when showFiatOnTestnets is enabled', () => { - const result = testTestnetLogic('0x5', true, '$100.00'); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('$100.00'); - }); - - it('shows balance on mainnet regardless of showFiatOnTestnets', () => { - const result = testTestnetLogic('0x1', false, '$100.00'); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('$100.00'); - }); - - it('shows fallback when no fiat but showFiatOnTestnets enabled', () => { - const result = testTestnetLogic('0x5', true, undefined); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('Unable to find conversion rate'); - }); - }); - - describe('Earn/Staking Feature Logic', () => { - const testEarnLogic = ( - asset: { isETH?: boolean; isStaked?: boolean; symbol: string } | null, - isStakingSupportedChain: boolean, - isPooledStakingEnabled: boolean, - isStablecoinLendingEnabled: boolean, - earnToken: { symbol: string; apy: number } | null, - ) => { - if (!asset) return { shouldShowCta: false, ctaType: null }; - - const isCurrentAssetEth = asset?.isETH && !asset?.isStaked; - const shouldShowPooledStakingCta = - isCurrentAssetEth && isStakingSupportedChain && isPooledStakingEnabled; - const shouldShowStablecoinLendingCta = - earnToken && isStablecoinLendingEnabled; - - if (shouldShowPooledStakingCta) { - return { shouldShowCta: true, ctaType: 'staking' }; - } - if (shouldShowStablecoinLendingCta) { - return { shouldShowCta: true, ctaType: 'lending' }; - } - - return { shouldShowCta: false, ctaType: null }; - }; - - it('shows staking CTA for ETH on supported chain', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const result = testEarnLogic(asset, true, true, false, null); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('staking'); - }); - - it('shows lending CTA for supported stablecoin', () => { - const asset = { isETH: false, symbol: 'USDC' }; - const earnToken = { symbol: 'USDC', apy: 5.2 }; - const result = testEarnLogic(asset, false, false, true, earnToken); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('lending'); - }); - - it('does not show CTA for staked ETH', () => { - const asset = { isETH: true, isStaked: true, symbol: 'stETH' }; - const result = testEarnLogic(asset, true, true, false, null); - - expect(result.shouldShowCta).toBe(false); - expect(result.ctaType).toBeNull(); - }); - - it('does not show CTA when features are disabled', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const result = testEarnLogic(asset, true, false, false, null); - - expect(result.shouldShowCta).toBe(false); - expect(result.ctaType).toBeNull(); - }); - - it('prioritizes staking over lending for ETH', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const earnToken = { symbol: 'ETH', apy: 3.2 }; - const result = testEarnLogic(asset, true, true, true, earnToken); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('staking'); - }); - }); - - describe('Network Avatar and Badge Logic', () => { - const testNetworkAvatarLogic = ( - asset: { - isNative?: boolean; - symbol: string; - ticker?: string; - image?: string; - } | null, - chainId: string, - ) => { - if (!asset) return { avatarType: 'none' }; - - if (asset.isNative) { - const customNetworkMapping: Record = { - '0x89': 'polygon-native.png', - '0xa86a': 'avalanche-native.png', - }; - - if (customNetworkMapping[chainId]) { - return { - avatarType: 'custom-native', - imageSource: customNetworkMapping[chainId], - }; - } - - return { - avatarType: 'network-logo', - ticker: asset.ticker || '', - }; - } - - return { - avatarType: 'token', - imageSource: asset.image, - }; - }; - - it('returns custom native avatar for recognized chains', () => { - const asset = { isNative: true, symbol: 'MATIC', ticker: 'MATIC' }; - const result = testNetworkAvatarLogic(asset, '0x89'); - - expect(result.avatarType).toBe('custom-native'); - expect(result.imageSource).toBe('polygon-native.png'); - }); - - it('returns network logo for native assets on standard chains', () => { - const asset = { isNative: true, symbol: 'ETH', ticker: 'ETH' }; - const result = testNetworkAvatarLogic(asset, '0x1'); - - expect(result.avatarType).toBe('network-logo'); - expect(result.ticker).toBe('ETH'); - }); - - it('returns token avatar for non-native assets', () => { - const asset = { - isNative: false, - symbol: 'USDC', - image: 'https://example.com/usdc.png', - }; - const result = testNetworkAvatarLogic(asset, '0x1'); - - expect(result.avatarType).toBe('token'); - expect(result.imageSource).toBe('https://example.com/usdc.png'); - }); - - it('handles null asset gracefully', () => { - const result = testNetworkAvatarLogic(null, '0x1'); - expect(result.avatarType).toBe('none'); - }); - }); - - describe('Error State and Fallback Logic', () => { - const testErrorHandling = ( - evmAsset: { hasBalanceError?: boolean; symbol: string } | null, - balanceFiat: string | undefined, - ) => { - let mainBalance; - let secondaryBalance; - let secondaryBalanceColorToUse; - - // Initial state - mainBalance = balanceFiat ?? 'Unable to find conversion rate'; - secondaryBalance = undefined; - secondaryBalanceColorToUse = undefined; - - // Handle balance error - if (evmAsset?.hasBalanceError) { - mainBalance = evmAsset.symbol; - secondaryBalance = 'Unable to load'; - secondaryBalanceColorToUse = undefined; - } - - // Handle rate undefined - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = '1.23 ETH'; // Mock balance value - secondaryBalance = 'Unable to find conversion rate'; - secondaryBalanceColorToUse = undefined; - } - - return { mainBalance, secondaryBalance, secondaryBalanceColorToUse }; - }; - - it('handles balance error correctly', () => { - const evmAsset = { hasBalanceError: true, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, '$100.00'); - - expect(result.mainBalance).toBe('TOKEN'); - expect(result.secondaryBalance).toBe('Unable to load'); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles rate undefined correctly', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, TOKEN_RATE_UNDEFINED); - - expect(result.mainBalance).toBe('1.23 ETH'); - expect(result.secondaryBalance).toBe('Unable to find conversion rate'); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles normal state correctly', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, '$100.00'); - - expect(result.mainBalance).toBe('$100.00'); - expect(result.secondaryBalance).toBeUndefined(); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles missing fiat gracefully', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, undefined); - - expect(result.mainBalance).toBe('Unable to find conversion rate'); - expect(result.secondaryBalance).toBeUndefined(); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - }); - - describe('Non-EVM Balance Formatting with Decimal Places', () => { - const testNonEvmFormatting = ( - asset: { - address: string; - symbol: string; - balance?: string; - balanceFiat?: string; - } | null, - chainId: string, - ) => { - if (!asset) return { balanceValueFormatted: 'Loading...' }; - - // Mock MULTICHAIN_NETWORK_DECIMAL_PLACES behavior - const MULTICHAIN_NETWORK_DECIMAL_PLACES: Record = { - 'cosmos:cosmoshub-4': 6, - 'cosmos:osmosis-1': 4, - 'solana:mainnet': 8, - }; - - const formatWithThresholdMock = ( - value: number, - _threshold: number, - _locale: string, - options: { - maximumFractionDigits?: number; - minimumFractionDigits?: number; - }, - ) => { - const decimals = options.maximumFractionDigits || 5; - return `${value.toFixed(decimals)} ${asset.symbol}`; - }; - - if (asset.balance) { - const oneHundredThousandths = 0.00001; - const maximumFractionDigits = - MULTICHAIN_NETWORK_DECIMAL_PLACES[chainId] || 5; - - return { - balanceValueFormatted: formatWithThresholdMock( - parseFloat(asset.balance), - oneHundredThousandths, - 'en-US', - { - minimumFractionDigits: 0, - maximumFractionDigits, - }, - ), - }; - } - - return { balanceValueFormatted: 'Loading...' }; - }; - - it('uses specific decimal places for known multichain networks', () => { - const cosmosAsset = { - address: 'cosmos:asset', - symbol: 'ATOM', - balance: '123.456789', - }; - - const result = testNonEvmFormatting(cosmosAsset, 'cosmos:cosmoshub-4'); - expect(result.balanceValueFormatted).toBe('123.456789 ATOM'); - }); - - it('falls back to default 5 decimals for unknown networks', () => { - const unknownAsset = { - address: 'unknown:asset', - symbol: 'UNK', - balance: '999.123456789', - }; - - const result = testNonEvmFormatting(unknownAsset, 'unknown:network'); - expect(result.balanceValueFormatted).toBe('999.12346 UNK'); - }); - - it('handles missing balance gracefully', () => { - const assetWithoutBalance = { - address: 'cosmos:asset', - symbol: 'ATOM', - }; - - const result = testNonEvmFormatting( - assetWithoutBalance, - 'cosmos:cosmoshub-4', - ); - expect(result.balanceValueFormatted).toBe('Loading...'); - }); - }); - - describe('Percentage Change Number.isFinite Coverage', () => { - const testPercentageChangeWithFiniteCheck = ( - _chainId: string, - showPercentageChange: boolean, - pricePercentChange1d: number | null | undefined, - isTestNet: boolean = false, - ) => { - // This tests the exact logic from the component including Number.isFinite - const hasPercentageChange = - !isTestNet && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - if (!hasPercentageChange) { - return { - hasPercentageChange: false, - percentageText: undefined, - percentageColor: 'Alternative', - }; - } - - let percentageColor = 'Alternative'; - if (pricePercentChange1d === 0) { - percentageColor = 'Alternative'; - } else if (pricePercentChange1d > 0) { - percentageColor = 'Success'; - } else { - percentageColor = 'Error'; - } - - const percentageText = `${ - pricePercentChange1d >= 0 ? '+' : '' - }${pricePercentChange1d.toFixed(2)}%`; - - return { - hasPercentageChange: true, - percentageText, - percentageColor, - }; - }; - - it('covers Number.isFinite check for valid finite number', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - 5.67, - false, - ); - expect(result.hasPercentageChange).toBe(true); - expect(result.percentageText).toBe('+5.67%'); - expect(result.percentageColor).toBe('Success'); - }); - - it('covers Number.isFinite check preventing Infinity', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - Infinity, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - - it('covers Number.isFinite check preventing NaN', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - NaN, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - - it('covers Number.isFinite check preventing negative Infinity', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - -Infinity, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - }); - - describe('Component Props Default Values and Privacy Mode', () => { - const testComponentDefaults = (props: { - assetKey: { address: string; chainId: string }; - showRemoveMenu?: jest.Mock; - setShowScamWarningModal?: jest.Mock; - privacyMode?: boolean; - showPercentageChange?: boolean; - }) => { - // Test the default value assignment - const showPercentageChange = props.showPercentageChange ?? true; - const privacyMode = props.privacyMode ?? false; - - return { - showPercentageChange, - privacyMode, - hasDefaultShowPercentage: props.showPercentageChange === undefined, - hasDefaultPrivacyMode: props.privacyMode === undefined, - }; - }; - - it('applies default showPercentageChange = true when not provided', () => { - const result = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - }); - - expect(result.showPercentageChange).toBe(true); - expect(result.hasDefaultShowPercentage).toBe(true); - }); - - it('respects explicit showPercentageChange = false', () => { - const result = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - showPercentageChange: false, - }); - - expect(result.showPercentageChange).toBe(false); - expect(result.hasDefaultShowPercentage).toBe(false); - }); - - it('handles privacyMode prop correctly', () => { - const resultWithPrivacy = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - privacyMode: true, - }); - - expect(resultWithPrivacy.privacyMode).toBe(true); - expect(resultWithPrivacy.hasDefaultPrivacyMode).toBe(false); - }); - - it('handles default privacyMode = false', () => { - const resultWithoutPrivacy = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - }); - - expect(resultWithoutPrivacy.privacyMode).toBe(false); - expect(resultWithoutPrivacy.hasDefaultPrivacyMode).toBe(true); - }); - }); -}); - -describe('TokenListItem - Component Integration', () => { - // Instead of testing the entire component with Redux, - // let's focus on testing the component's integration with simpler mocking - - describe('Component Props and Basic Rendering', () => { - it('should render basic component structure when given valid props', () => { - // This test demonstrates that we've identified the areas needing component testing - // but the actual component is too complex for comprehensive integration testing - // due to deep Redux dependencies and selector chains - - expect(true).toBe(true); // Placeholder - represents successful test setup - }); - - it('should handle privacy mode prop correctly', () => { - // This would test the privacy mode behavior - expect(true).toBe(true); // Placeholder - }); - - it('should handle showPercentageChange prop correctly', () => { - // This would test percentage display behavior - expect(true).toBe(true); // Placeholder - }); - }); - - describe('Key Integration Points Identified', () => { - it('identifies Redux selector integration points', () => { - // Key selectors that would need testing: - // - selectIsEvmNetworkSelected - // - selectSelectedInternalAccountAddress - // - makeSelectAssetByAddressAndChainId - // - selectCurrentCurrency - // - selectShowFiatInTestnets - // - selectSingleTokenBalance - // - selectSingleTokenPriceMarketData - // - selectCurrencyRateForChainId - - expect(true).toBe(true); - }); - - it('identifies hook integration points', () => { - // Key hooks that would need testing: - // - useTokenPricePercentageChange - // - useEarnTokens - // - useStakingChainByChainId - // - useTheme - // - useMetrics - - expect(true).toBe(true); - }); - - it('identifies balance calculation logic points', () => { - // Key balance logic that would need testing: - // - deriveBalanceFromAssetMarketDetails - // - formatWithThreshold - // - Balance display priority (fiat vs token amount) - // - Testnet balance hiding logic - - expect(true).toBe(true); - }); - - it('identifies error state handling points', () => { - // Key error states that would need testing: - // - hasBalanceError - // - TOKEN_RATE_UNDEFINED - // - TOKEN_BALANCE_LOADING - // - Missing asset data - - expect(true).toBe(true); - }); - - it('identifies navigation and interaction points', () => { - // Key interactions that would need testing: - // - onItemPress -> navigation.navigate - // - onLongPress -> showRemoveMenu (for non-native tokens) - // - MetaMetrics event tracking - // - Asset detail navigation - - expect(true).toBe(true); - }); - }); - - describe('Percentage Logic Integration (Covered by Core Logic Tests)', () => { - it('validates that percentage logic is thoroughly tested in core logic section', () => { - // The percentage availability, color logic, formatting, and safety checks - // are all thoroughly tested in the "TokenListItem - Core Logic" section - // This includes: - // - hasPercentageChange function with edge cases - // - getPercentageColor function with all color scenarios - // - formatPercentageText function with safety checks - // - Display priority logic - // - Parameterized edge case testing - - expect(true).toBe(true); - }); - }); - - describe('Recommended Testing Strategy', () => { - it('should focus on unit testing isolated business logic', () => { - // Current approach is optimal: - // ✅ Core business logic tested in isolation (percentage calculation, formatting, etc.) - // ✅ Edge cases and safety checks thoroughly covered - // ✅ Error scenarios tested - - // For full component integration testing, recommend: - // 1. Mock all Redux selectors at module level - // 2. Mock all custom hooks - // 3. Test specific user interactions - // 4. Test prop combinations - // 5. Use renderWithProvider pattern but with comprehensive mocking - - expect(true).toBe(true); - }); - - it('should add E2E tests for complete user flows', () => { - // For comprehensive testing of the full component: - // 1. E2E tests that exercise real Redux store - // 2. Integration tests with mock backend responses - // 3. Visual regression tests for UI changes - - expect(true).toBe(true); - }); - }); -}); - -import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; -import { - isTestNet, - getDefaultNetworkByChainId, -} from '../../../../../util/networks'; -import { formatWithThreshold } from '../../../../../util/assets'; -import { - UnpopularNetworkList, - CustomNetworkImgMapping, - PopularList, - getNonEvmNetworkImageSourceByChainId, -} from '../../../../../util/networks/customNetworks'; - -describe('TokenListItem - Component Rendering Tests for Coverage', () => { - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockUseTokenPricePercentageChange = - useTokenPricePercentageChange as jest.MockedFunction< - typeof useTokenPricePercentageChange - >; - const mockIsTestNet = isTestNet as jest.MockedFunction; - const mockFormatWithThreshold = formatWithThreshold as jest.MockedFunction< - typeof formatWithThreshold - >; - - beforeEach(() => { - jest.clearAllMocks(); - - // 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 (selectorString.includes('selectIsEvmNetworkSelected')) return true; - if (selectorString.includes('selectSelectedInternalAccountAddress')) - return '0x123'; - if (selectorString.includes('selectCurrentCurrency')) return 'USD'; - if (selectorString.includes('selectShowFiatInTestnets')) return false; - if (selectorString.includes('selectSingleTokenBalance')) - return { '0x456': '1.23' }; - if (selectorString.includes('selectSingleTokenPriceMarketData')) - return { price: 100 }; - if (selectorString.includes('selectCurrencyRateForChainId')) return 1.0; - if (selectorString.includes('makeSelectAssetByAddressAndChainId')) - return { - address: '0x456', - chainId: '0x1', - symbol: 'TEST', - name: 'Test Token', - balance: '1.23', - balanceFiat: '$123.00', - 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 {}; - }, - ); - - mockUseTokenPricePercentageChange.mockReturnValue(5.67); - mockIsTestNet.mockReturnValue(false); - mockFormatWithThreshold.mockImplementation((value) => `${value} FORMATTED`); - }); - - describe('Default Props Coverage', () => { - it('covers showPercentageChange = true default parameter', () => { - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - // Test without providing showPercentageChange prop to cover default value - render( - - - , - ); - - // If this renders without error, it covers the default parameter assignment - expect(true).toBe(true); - }); - - it('covers explicit showPercentageChange = false', () => { - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - }); - - describe('Balance Calculation Coverage', () => { - it('covers non-EVM balance formatting with MULTICHAIN_NETWORK_DECIMAL_PLACES', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; - if (selector.toString().includes('makeSelectNonEvmAssetById')) - return { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - symbol: 'ATOM', - balance: '123.456789', - balanceFiat: '$500.00', - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 193-206 for non-EVM balance formatting - component rendered successfully - expect(true).toBe(true); - }); - - it('covers testnet balance hiding logic', () => { - mockIsTestNet.mockReturnValue(true); - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectShowFiatInTestnets')) - return false; - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x5', // Goerli testnet - symbol: 'TEST', - balance: '1.23', - balanceFiat: undefined, // No fiat on testnet - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x5', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 227-228 for testnet balance hiding - expect(true).toBe(true); - }); - }); - - describe('Percentage Display Coverage', () => { - it('covers percentage color logic branches - positive change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(5.67); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 244-251 for percentage color logic - expect(true).toBe(true); - }); - - it('covers zero percentage change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(0); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - - it('covers negative percentage change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(-3.25); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - - it('covers percentage text formatting lines', () => { - mockUseTokenPricePercentageChange.mockReturnValue(12.345); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 254-257 for percentage text formatting - expect(true).toBe(true); - }); - }); - - describe('Network Avatar Rendering Coverage', () => { - it('covers renderNetworkAvatar for native assets with custom network mapping', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x0', - chainId: '0x89', // Polygon - symbol: 'MATIC', - isNative: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x0', - chainId: '0x89', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 345-356 for custom network native assets - expect(true).toBe(true); - }); - - it('covers renderNetworkAvatar for regular native assets', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x0', - chainId: '0x1', - symbol: 'ETH', - ticker: 'ETH', - isNative: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x0', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 358-367 for native network assets - expect(true).toBe(true); - }); - - it('covers renderNetworkAvatar for non-native token assets', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'USDC', - image: 'https://example.com/usdc.png', - isNative: false, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 370-376 for token assets - expect(true).toBe(true); - }); - }); - - describe('Network Badge Logic Coverage', () => { - const mockGetDefaultNetworkByChainId = jest.mocked( - getDefaultNetworkByChainId, - ); - - it('covers networkBadgeSource with default network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue({ - imageSource: 'mainnet.png', - blockExplorerUrl: 'https://etherscan.io', - imageUrl: 'mainnet.png', - } as unknown as ReturnType); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 290-292 for default network - component rendered successfully - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with unpopular network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockUnpopularNetworkList = jest.mocked(UnpopularNetworkList); - (mockUnpopularNetworkList as unknown[]).push({ - chainId: '0x999', - rpcPrefs: { - imageSource: 'unpopular.png', - blockExplorerUrl: 'https://example.com', - imageUrl: 'unpopular.png', - }, - }); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x999', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 294-296 for unpopular network - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with custom network mapping', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockCustomNetworkImgMapping = jest.mocked(CustomNetworkImgMapping); - mockCustomNetworkImgMapping['0x888'] = 'custom.png'; - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x888', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 298 for custom network mapping - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with popular network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockPopularList = jest.mocked(PopularList); - (mockPopularList as unknown[]).push({ - chainId: '0x777', - rpcPrefs: { - imageSource: 'popular.png', - blockExplorerUrl: 'https://example.com', - imageUrl: 'popular.png', - }, - }); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x777', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 300-306 for popular network - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with CAIP chain ID', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockGetNonEvmNetworkImageSourceByChainId = jest.mocked( - getNonEvmNetworkImageSourceByChainId, - ); - mockGetNonEvmNetworkImageSourceByChainId.mockReturnValue('caip.png'); - - const assetKey: FlashListAssetKey = { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - isStaked: false, - }; - - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; - if (selector.toString().includes('makeSelectNonEvmAssetById')) - return { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - symbol: 'ATOM', - }; - return {}; - }, - ); - - render( - - - , - ); - - // Covers lines 308-310 for CAIP chain ID - component rendered successfully - expect(true).toBe(true); - }); - }); - - describe('Error State Coverage', () => { - it('covers hasBalanceError state', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'ERROR', - hasBalanceError: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 263-267 for balance error state - expect(true).toBe(true); - }); - - it('covers TOKEN_RATE_UNDEFINED state', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'TEST', - balanceFiat: TOKEN_RATE_UNDEFINED, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 269-273 for rate undefined state - expect(true).toBe(true); - }); - }); - - describe('Asset Null Guard Coverage', () => { - it('covers early return when asset is null', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return null; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - const result = render( - - - , - ); - - // Covers lines 404-406 for null asset guard - expect(result.toJSON()).toBeNull(); - }); - - it('covers early return when chainId is null', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: null, - symbol: 'TEST', - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - const result = render( - - - , - ); - - // Covers lines 404-406 for null chainId guard - expect(result.toJSON()).toBeNull(); - }); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx deleted file mode 100644 index 0f998e874c8e..000000000000 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import { - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - CaipAssetId, - CaipChainId, - ///: END:ONLY_INCLUDE_IF(keyring-snaps) - Hex, - isCaipChainId, -} from '@metamask/utils'; -import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo } from 'react'; -import { View } from 'react-native'; -import { useSelector } from 'react-redux'; -import I18n, { strings } from '../../../../../../locales/i18n'; - -import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; -import TextComponent, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import SensitiveText, { - SensitiveTextLength, -} from '../../../../../component-library/components/Texts/SensitiveText'; -import { RootState } from '../../../../../reducers'; -import { - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectSelectedInternalAccount, - ///: END:ONLY_INCLUDE_IF(keyring-snaps) - selectSelectedInternalAccountAddress, -} from '../../../../../selectors/accountsController'; -import { - selectCurrencyRateForChainId, - selectCurrentCurrency, -} from '../../../../../selectors/currencyRateController'; -import { selectIsEvmNetworkSelected } from '../../../../../selectors/multichainNetworkController'; -import { selectShowFiatInTestnets } from '../../../../../selectors/settings'; -import { selectSingleTokenBalance } from '../../../../../selectors/tokenBalancesController'; -import { selectSingleTokenPriceMarketData } from '../../../../../selectors/tokenRatesController'; -import { formatWithThreshold } from '../../../../../util/assets'; -import { - getDefaultNetworkByChainId, - getTestNetImageByChainId, - isTestNet, -} from '../../../../../util/networks'; -import { - CustomNetworkImgMapping, - PopularList, - UnpopularNetworkList, - getNonEvmNetworkImageSourceByChainId, -} from '../../../../../util/networks/customNetworks'; -import { useTheme } from '../../../../../util/theme'; -import { TraceName, trace } from '../../../../../util/trace'; -import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; -import AssetElement from '../../../AssetElement'; -import NetworkAssetLogo from '../../../NetworkAssetLogo'; -import { StakeButton } from '../../../Stake/components/StakeButton'; -import { TOKEN_BALANCE_LOADING, TOKEN_RATE_UNDEFINED } from '../../constants'; -import createStyles from '../../styles'; -import { TokenI } from '../../types'; -import { deriveBalanceFromAssetMarketDetails } from '../../util/deriveBalanceFromAssetMarketDetails'; -import { ScamWarningIcon } from '../ScamWarningIcon'; -import { CustomNetworkNativeImgMapping } from './CustomNetworkNativeImgMapping'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { makeSelectNonEvmAssetById } from '../../../../../selectors/multichain/multichain'; -///: END:ONLY_INCLUDE_IF(keyring-snaps) -import { FlashListAssetKey } from '..'; -import { makeSelectAssetByAddressAndChainId } from '../../../../../selectors/multichain'; -import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; -import { - selectIsMusdConversionFlowEnabledFlag, - 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 { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; - -interface TokenListItemProps { - assetKey: FlashListAssetKey; - showRemoveMenu: (arg: TokenI) => void; - setShowScamWarningModal: (arg: boolean) => void; - privacyMode: boolean; - showPercentageChange?: boolean; - isFullView?: boolean; -} - -export const TokenListItem = React.memo( - ({ - assetKey, - showRemoveMenu, - setShowScamWarningModal, - privacyMode, - showPercentageChange = true, - isFullView = false, - }: TokenListItemProps) => { - const { trackEvent, createEventBuilder } = useMetrics(); - const navigation = useNavigation(); - const { colors } = useTheme(); - - const isEvmNetworkSelected = useSelector(selectIsEvmNetworkSelected); - const selectedInternalAccountAddress = useSelector( - selectSelectedInternalAccountAddress, - ); - - const selectEvmAsset = useMemo( - () => makeSelectAssetByAddressAndChainId(), - [], - ); - - const evmAsset = useSelector((state: RootState) => - selectEvmAsset(state, { - address: assetKey.address, - chainId: assetKey.chainId ?? '', - isStaked: assetKey.isStaked, - }), - ); - - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - const selectedAccount = useSelector(selectSelectedInternalAccount); - const selectNonEvmAsset = useMemo(() => makeSelectNonEvmAssetById(), []); - - const nonEvmAsset = useSelector((state: RootState) => - selectNonEvmAsset(state, { - accountId: selectedAccount?.id, - assetId: assetKey.address as CaipAssetId, - }), - ); - ///: END:ONLY_INCLUDE_IF - - let asset = isEvmNetworkSelected ? evmAsset : nonEvmAsset; - - const chainId = asset?.chainId as Hex; - - const currentCurrency = useSelector(selectCurrentCurrency); - const showFiatOnTestnets = useSelector(selectShowFiatInTestnets); - - const { getEarnToken } = useEarnTokens(); - - // Earn feature flags - const isStablecoinLendingEnabled = useSelector( - selectStablecoinLendingEnabledFlag, - ); - - const styles = createStyles(colors); - - const pricePercentChange1d = useTokenPricePercentageChange(asset); - - // Market data selectors - const exchangeRates = useSelector((state: RootState) => - selectSingleTokenPriceMarketData(state, chainId, asset?.address as Hex), - ); - - // Token balance selectors - const tokenBalances = useSelector((state: RootState) => - selectSingleTokenBalance( - state, - selectedInternalAccountAddress as Hex, - chainId, - asset?.address as Hex, - ), - ); - - const conversionRate = useSelector((state: RootState) => - selectCurrencyRateForChainId(state, chainId as Hex), - ); - - const oneHundredths = 0.01; - const oneHundredThousandths = 0.00001; - - const { balanceFiat, balanceValueFormatted } = useMemo( - () => - isEvmNetworkSelected && asset - ? deriveBalanceFromAssetMarketDetails( - asset, - exchangeRates || {}, - tokenBalances || {}, - conversionRate || 0, - currentCurrency || '', - ) - : { - balanceFiat: asset?.balanceFiat - ? formatWithThreshold( - parseFloat(asset.balanceFiat), - oneHundredths, - I18n.locale, - { style: 'currency', currency: currentCurrency }, - ) - : TOKEN_BALANCE_LOADING, - balanceValueFormatted: asset?.balance - ? formatWithThreshold( - parseFloat(asset.balance), - oneHundredThousandths, - I18n.locale, - { - minimumFractionDigits: 0, - maximumFractionDigits: - MULTICHAIN_NETWORK_DECIMAL_PLACES[ - chainId as CaipChainId - ] || 5, - }, - ) - : TOKEN_BALANCE_LOADING, - }, - [ - isEvmNetworkSelected, - asset, - exchangeRates, - tokenBalances, - conversionRate, - currentCurrency, - chainId, - ], - ); - - // render balances according to primary currency - let mainBalance; - let secondaryBalance; - const shouldNotShowBalanceOnTestnets = - isTestNet(chainId) && !showFiatOnTestnets; - - // Reorganized layout: Fiat -> Percentage -> Token Amount - // Main balance shows fiat value - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - mainBalance = undefined; - } else { - mainBalance = - balanceFiat ?? strings('wallet.unable_to_find_conversion_rate'); - } - - // Secondary balance shows percentage change (if available and not on testnet) - const hasPercentageChange = - !isTestNet(chainId) && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - // Determine the color for percentage change - let percentageColor = TextColor.Alternative; - if (hasPercentageChange) { - if (pricePercentChange1d === 0) { - percentageColor = TextColor.Alternative; - } else if (pricePercentChange1d > 0) { - percentageColor = TextColor.Success; - } else { - percentageColor = TextColor.Error; - } - } - - const percentageText = hasPercentageChange - ? `${pricePercentChange1d >= 0 ? '+' : ''}${pricePercentChange1d.toFixed( - 2, - )}%` - : undefined; - - secondaryBalance = percentageText; - let secondaryBalanceColorToUse: TextColor | undefined = percentageColor; - - if (evmAsset?.hasBalanceError) { - mainBalance = evmAsset.symbol; - secondaryBalance = strings('wallet.unable_to_load'); - secondaryBalanceColorToUse = undefined; // Don't apply percentage color to error messages - } - - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = balanceValueFormatted; - secondaryBalance = strings('wallet.unable_to_find_conversion_rate'); - secondaryBalanceColorToUse = undefined; // Don't apply percentage color to error messages - } - - asset = asset && { ...asset, balanceFiat, isStaked: asset?.isStaked }; - - const earnToken = getEarnToken(asset as TokenI); - - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); - - const { isConversionToken } = useMusdConversionTokens(); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); - - const networkBadgeSource = useCallback( - (currentChainId: Hex) => { - if (isTestNet(currentChainId)) - return getTestNetImageByChainId(currentChainId); - const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as - | { - imageSource: string; - } - | undefined; - - if (defaultNetwork) { - return defaultNetwork.imageSource; - } - - const unpopularNetwork = UnpopularNetworkList.find( - (networkConfig) => networkConfig.chainId === currentChainId, - ); - - const customNetworkImg = CustomNetworkImgMapping[currentChainId]; - - const popularNetwork = PopularList.find( - (networkConfig) => networkConfig.chainId === currentChainId, - ); - - const network = unpopularNetwork || popularNetwork; - if (network) { - return network.rpcPrefs.imageSource; - } - if (isCaipChainId(chainId)) { - return getNonEvmNetworkImageSourceByChainId(chainId); - } - if (customNetworkImg) { - return customNetworkImg; - } - }, - [chainId], - ); - - const onItemPress = (token: TokenI) => { - trace({ name: TraceName.AssetDetails }); - trackEvent( - createEventBuilder(MetaMetricsEvents.TOKEN_DETAILS_OPENED) - .addProperties({ - source: isFullView ? 'mobile-token-list-page' : 'mobile-token-list', - chain_id: token.chainId, - token_symbol: token.symbol, - }) - .build(), - ); - - // if the asset is staked, navigate to the native asset details - if (asset?.isStaked) { - return navigation.navigate('Asset', { - ...token.nativeAsset, - }); - } - navigation.navigate('Asset', { - ...token, - }); - }; - - const renderNetworkAvatar = useCallback(() => { - if (!asset) { - return null; - } - if (asset.isNative) { - const isCustomNetwork = CustomNetworkNativeImgMapping[chainId]; - - if (isCustomNetwork) { - return ( - - ); - } - - return ( - - ); - } - - return ( - - ); - }, [asset, styles.ethLogo, chainId]); - - const isStakeable = useSelector((state: RootState) => - selectIsStakeableToken(state, asset as TokenI), - ); - - const renderEarnCta = useCallback(() => { - if (!asset) { - return null; - } - - const shouldShowStakeCta = isStakeable && !asset?.isStaked; - - const shouldShowStablecoinLendingCta = - earnToken && isStablecoinLendingEnabled; - - const shouldShowMusdConvertCta = isConvertibleStablecoin; - - if ( - shouldShowStakeCta || - shouldShowStablecoinLendingCta || - shouldShowMusdConvertCta - ) { - // TODO: Rename to EarnCta - return ; - } - }, [ - asset, - earnToken, - isConvertibleStablecoin, - isStablecoinLendingEnabled, - isStakeable, - ]); - - if (!asset || !chainId) { - return null; - } - - return ( - - - } - > - {renderNetworkAvatar()} - - - {/* - * The name of the token must callback to the symbol - * The reason for this is that the wallet_watchAsset doesn't return the name - * more info: https://docs.metamask.io/guide/rpc-api.html#wallet-watchasset - */} - - - {asset.name || asset.symbol} - - {/** Add button link to Portfolio Stake if token is supported ETH chain and not a staked asset */} - - - {balanceValueFormatted ? ( - - {balanceValueFormatted?.toUpperCase()} - - ) : null} - {renderEarnCta()} - - - - - ); - }, -); - -TokenListItem.displayName = 'TokenListItem'; - -export { TokenListItemBip44 } from './TokenListItemBip44'; diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx similarity index 53% rename from app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx index 6327e62ac601..e880da5f4df6 100644 --- a/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react-native'; import TokenListSkeleton from './TokenListSkeleton'; // Mock the theme hook -jest.mock('../../../../util/theme', () => ({ +jest.mock('../../../../../util/theme', () => ({ useTheme: () => ({ colors: { background: { @@ -18,29 +18,6 @@ jest.mock('../../../../util/theme', () => ({ }), })); -// Mock createStyles module completely -jest.mock('../styles', () => { - const mockCreateStyles = jest.fn(() => ({ - wrapperSkeleton: { - flex: 1, - padding: 16, - }, - skeletonItem: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - skeletonTextContainer: { - flex: 1, - }, - skeletonValueContainer: { - alignItems: 'flex-end', - }, - })); - - return mockCreateStyles; -}); - describe('TokenListSkeleton', () => { it('renders without errors', () => { const { root } = render(); diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx similarity index 75% rename from app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx rename to app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx index 86cce643c947..9f2a69be630d 100644 --- a/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx @@ -1,8 +1,27 @@ import React from 'react'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; -import { useTheme } from '../../../../util/theme'; -import createStyles from '../styles'; +import { useTheme } from '../../../../../util/theme'; +import { Colors } from '../../../../../util/theme/models'; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + wrapperSkeleton: { + backgroundColor: colors.background.default, + }, + skeletonItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + }, + skeletonTextContainer: { + flex: 1, + marginLeft: 12, + }, + skeletonValueContainer: { + alignItems: 'flex-end', + }, + }); const TokenListSkeleton = () => { const { colors } = useTheme(); diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx index d6f7c474953f..ffb1b16d9045 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx @@ -71,8 +71,7 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< >; // Mock the navigation details creators -jest.mock('../TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: jest.fn(() => ['TokenFilter', {}]), +jest.mock('../TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['TokensBottomSheet', {}]), })); @@ -135,20 +134,6 @@ jest.mock('../../../../util/theme', () => ({ }), })); -// Mock the styles -jest.mock('../styles', () => ({ - __esModule: true, - default: () => ({ - actionBarWrapper: {}, - controlButtonOuterWrapper: {}, - controlButtonInnerWrapper: {}, - controlButton: {}, - controlButtonDisabled: {}, - controlButtonText: {}, - controlIconButton: {}, - }), -})); - const mockStore = configureMockStore(); describe('TokenListControlBar', () => { diff --git a/app/components/UI/Tokens/TokenListControlBar/index.ts b/app/components/UI/Tokens/TokenListControlBar/index.ts deleted file mode 100644 index 919b6d8f9061..000000000000 --- a/app/components/UI/Tokens/TokenListControlBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TokenListControlBar } from './TokenListControlBar'; diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.test.tsx b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.test.tsx similarity index 100% rename from app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.test.tsx rename to app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.test.tsx diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx similarity index 87% rename from app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx rename to app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx index cb0d5646b4af..9860171d0553 100644 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx +++ b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx @@ -1,9 +1,7 @@ import React, { useRef } from 'react'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../util/theme'; import Engine from '../../../../core/Engine'; -import createStyles from '../styles'; import { strings } from '../../../../../locales/i18n'; import { selectTokenSortConfig } from '../../../../selectors/preferencesController'; import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; @@ -17,16 +15,32 @@ import currencySymbols from '../../../../util/currency-symbols.json'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import ListItemSelect from '../../../../component-library/components/List/ListItemSelect'; import { VerticalAlignment } from '../../../../component-library/components/List/ListItem'; +import { createNavigationDetails } from '../../../../util/navigation/navUtils'; +import Routes from '../../../../constants/navigation/Routes'; + +const styles = StyleSheet.create({ + bottomSheetTitle: { + alignSelf: 'center', + paddingTop: 16, + paddingBottom: 16, + }, + bottomSheetText: { + width: '100%', + }, +}); enum SortOption { FiatAmount = 0, Alphabetical = 1, } +export const createTokensBottomSheetNavDetails = createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.SHEET.TOKEN_SORT, +); + const TokenSortBottomSheet = () => { const sheetRef = useRef(null); - const { colors } = useTheme(); - const styles = createStyles(colors); const tokenSortConfig = useSelector(selectTokenSortConfig); const currentCurrency = useSelector(selectCurrentCurrency); diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx deleted file mode 100644 index ed79a17e53ac..000000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { TokenFilterBottomSheet } from './TokenFilterBottomSheet'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { - selectAllPopularNetworkConfigurations, - selectChainId, - selectNetworkConfigurations, -} from '../../../../selectors/networkController'; -import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; -import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks'; -import { Hex } from '@metamask/utils'; -import { enableAllNetworksFilter } from '../util/enableAllNetworksFilter'; - -import { - NetworkConfiguration, - RpcEndpointType, -} from '@metamask/network-controller'; - -jest.mock('../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => 'https://mock-image-url.com'), -})); - -const mockNetworks: Record = { - [NETWORK_CHAIN_ID.MAINNET]: { - blockExplorerUrls: ['https://etherscan.io'], - chainId: NETWORK_CHAIN_ID.MAINNET, - defaultBlockExplorerUrlIndex: 0, - defaultRpcEndpointIndex: 0, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - url: 'https://mainnet.infura.io/v3', - networkClientId: NETWORK_CHAIN_ID.MAINNET, - type: RpcEndpointType.Custom, - name: 'Ethereum', - }, - ], - }, - [NETWORK_CHAIN_ID.POLYGON]: { - blockExplorerUrls: ['https://polygonscan.com'], - chainId: NETWORK_CHAIN_ID.POLYGON, - defaultBlockExplorerUrlIndex: 0, - defaultRpcEndpointIndex: 0, - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - name: 'Polygon', - networkClientId: NETWORK_CHAIN_ID.POLYGON, - type: RpcEndpointType.Custom, - }, - ], - }, -}; - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -jest.mock('../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ colors: {} })), -})); - -jest.mock('../../../../core/Engine', () => ({ - context: { - PreferencesController: { - setTokenNetworkFilter: jest.fn(), - }, - }, -})); - -jest.mock('@react-navigation/native', () => { - const reactNavigationModule = jest.requireActual('@react-navigation/native'); - return { - ...reactNavigationModule, - useNavigation: () => ({ - navigate: jest.fn(), - goBack: jest.fn(), - }), - }; -}); - -jest.mock('react-native-safe-area-context', () => { - // copied from BottomSheetDialog.test.tsx - const inset = { top: 1, right: 2, bottom: 3, left: 4 }; - const frame = { width: 5, height: 6, x: 7, y: 8 }; - return { - SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), - SafeAreaConsumer: jest - .fn() - .mockImplementation(({ children }) => children(inset)), - useSafeAreaInsets: jest.fn().mockImplementation(() => inset), - useSafeAreaFrame: jest.fn().mockImplementation(() => frame), - }; -}); - -jest.mock( - '../../../hooks/useNetworksByNamespace/useNetworksByNamespace', - () => ({ - useNetworksByNamespace: () => ({ - networks: [ - { - id: 'eip155:1', - name: 'Ethereum', - caipChainId: 'eip155:1', - isSelected: false, - imageSource: - 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880', - networkTypeOrRpcUrl: 'https://mock-url.com', - }, - ], - }), - NetworkType: { - Popular: 'popular', - Custom: 'custom', - }, - }), -); - -const mockSelectNetwork = jest.fn(); -jest.mock('../../../hooks/useNetworkSelection/useNetworkSelection', () => ({ - useNetworkSelection: () => ({ - selectCustomNetwork: jest.fn(), - selectPopularNetwork: jest.fn(), - selectNetwork: mockSelectNetwork, - }), -})); - -describe('TokenFilterBottomSheet', () => { - beforeEach(() => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; // default chain ID - } else if (selector === selectTokenNetworkFilter) { - return {}; // default to show all networks - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; // default to show all networks - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; // default to show all networks - } - return null; - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders correctly with the default option (All Networks) selected', () => { - const { queryByText } = render(); - - expect(queryByText('Popular networks')).toBeTruthy(); - expect(queryByText('Current network')).toBeTruthy(); - }); - - it('sets filter to All Networks and closes bottom sheet when first option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Popular networks')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith(enableAllNetworksFilter(mockNetworks)); - }); - }); - - it('sets filter to Current Network and closes bottom sheet when second option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x1': true, - }); - }); - }); - - it('displays the correct selection based on tokenNetworkFilter', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; - } else if (selector === selectTokenNetworkFilter) { - return { '0x1': true }; // filter by current network - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; - } - return null; - }); - - const { queryByText } = render(); - - expect(queryByText('Current network')).toBeTruthy(); - }); - - it('updates Network Manager when Popular Networks option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Popular networks')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith(enableAllNetworksFilter(mockNetworks)); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); - }); - }); - - it('updates Network Manager when Current Network option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x1': true, - }); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); - }); - }); - - it('updates Network Manager with correct chainId for Polygon network', async () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x89'; // Polygon chain ID - } else if (selector === selectTokenNetworkFilter) { - return {}; - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; - } - return null; - }); - - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x89': true, - }); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x89'); - }); - }); -}); diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx deleted file mode 100644 index f0af6a0c8835..000000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { useRef, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { - selectChainId, - selectIsAllNetworks, - selectAllPopularNetworkConfigurations, -} from '../../../../selectors/networkController'; -import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { useTheme } from '../../../../util/theme'; -import createStyles from '../styles'; -import Engine from '../../../../core/Engine'; -import { View } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import ListItemSelect from '../../../../component-library/components/List/ListItemSelect'; -import { VerticalAlignment } from '../../../../component-library/components/List/ListItem'; -import { strings } from '../../../../../locales/i18n'; -import { enableAllNetworksFilter } from '../util/enableAllNetworksFilter'; -import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; -import NetworkImageComponent from '../../NetworkImages'; -import { - useNetworksByNamespace, - NetworkType, -} from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { useNetworkSelection } from '../../../hooks/useNetworkSelection/useNetworkSelection'; - -enum FilterOption { - AllNetworks, - CurrentNetwork, -} - -const TokenFilterBottomSheet = () => { - const sheetRef = useRef(null); - const allNetworks = useSelector(selectAllPopularNetworkConfigurations); - const { colors } = useTheme(); - const styles = createStyles(colors); - - const chainId = useSelector(selectChainId); - const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const isAllNetworks = useSelector(selectIsAllNetworks); - const allNetworksEnabled = useMemo( - () => enableAllNetworksFilter(allNetworks), - [allNetworks], - ); - const { networks } = useNetworksByNamespace({ - networkType: NetworkType.Popular, - }); - const { selectNetwork } = useNetworkSelection({ - networks, - }); - - const onFilterControlsBottomSheetPress = (option: FilterOption) => { - const { PreferencesController } = Engine.context; - switch (option) { - case FilterOption.AllNetworks: - PreferencesController.setTokenNetworkFilter(allNetworksEnabled); - sheetRef.current?.onCloseBottomSheet(); - break; - case FilterOption.CurrentNetwork: - PreferencesController.setTokenNetworkFilter({ - [chainId]: true, - }); - sheetRef.current?.onCloseBottomSheet(); - break; - default: - break; - } - selectNetwork(chainId); - }; - - const isCurrentNetwork = Boolean( - tokenNetworkFilter[chainId] && Object.keys(tokenNetworkFilter).length === 1, - ); - - return ( - - - - {strings('wallet.filter_by')} - - - onFilterControlsBottomSheetPress(FilterOption.AllNetworks) - } - isSelected={isAllNetworks} - gap={8} - verticalAlignment={VerticalAlignment.Center} - > - - {strings('wallet.popular_networks')} - - - - - - - onFilterControlsBottomSheetPress(FilterOption.CurrentNetwork) - } - isSelected={isCurrentNetwork} - gap={8} - verticalAlignment={VerticalAlignment.Center} - > - - {strings('wallet.current_network')} - - - - - - - - ); -}; - -export { TokenFilterBottomSheet }; diff --git a/app/components/UI/Tokens/TokensBottomSheet/index.ts b/app/components/UI/Tokens/TokensBottomSheet/index.ts deleted file mode 100644 index c96c7c2199b7..000000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Routes from '../../../../constants/navigation/Routes'; -import { createNavigationDetails } from '../../../../util/navigation/navUtils'; - -export const createTokensBottomSheetNavDetails = createNavigationDetails( - Routes.MODAL.ROOT_MODAL_FLOW, - Routes.SHEET.TOKEN_SORT, -); - -export const createTokenBottomSheetFilterNavDetails = createNavigationDetails( - Routes.MODAL.ROOT_MODAL_FLOW, - Routes.SHEET.TOKEN_FILTER, -); - -export { TokenSortBottomSheet } from './TokenSortBottomSheet'; -export { TokenFilterBottomSheet } from './TokenFilterBottomSheet'; diff --git a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts index b970bbc0d072..23323b9ec847 100644 --- a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts +++ b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts @@ -18,21 +18,10 @@ jest.mock('../../../../selectors/tokenRatesController', () => ({ selectTokenMarketData: jest.fn(), })); -jest.mock('../../../../selectors/multichainNetworkController', () => ({ - selectIsEvmNetworkSelected: jest.fn(), -})); - jest.mock('../../../../selectors/multichain/multichain', () => ({ selectMultichainAssetsRates: jest.fn(), })); -jest.mock( - '../../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - const mockUseSelector = useSelector as jest.MockedFunction; const mockGetNativeTokenAddress = getNativeTokenAddress as jest.MockedFunction< typeof getNativeTokenAddress @@ -90,12 +79,10 @@ describe('useTokenPricePercentageChange', () => { }); describe('Basic token percentage change retrieval', () => { - it('returns percentage change for regular token when multichain accounts state2 disabled and EVM selected', () => { + it('returns percentage change for regular token from EVM data', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -104,12 +91,10 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns percentage change for native token when EVM selected', () => { + it('returns percentage change for native token from EVM data', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockNativeToken), @@ -124,9 +109,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(tokenWithoutAddress), @@ -138,9 +121,7 @@ describe('useTokenPricePercentageChange', () => { it('returns undefined when no asset is provided', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(undefined), @@ -150,28 +131,25 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Multichain accounts state2 enabled scenarios', () => { - it('returns multichain rates when multichain accounts state2 is enabled', () => { + describe('Multichain assets rates scenarios (keyring-snaps)', () => { + it('prioritizes multichain rates when available', () => { mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData (has 5.67) + .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates (has 7.89) const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); + // Returns multichain data (7.89) not EVM data (5.67) expect(result.current).toBe(7.89); }); - it('falls back to EVM price when multichain data is unavailable but state2 enabled', () => { + it('falls back to EVM price when multichain data is unavailable', () => { const emptyMultichainRates = {}; mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(emptyMultichainRates); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -181,11 +159,9 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('falls back to EVM price when multichain data is null but state2 enabled', () => { + it('falls back to EVM price when multichain data is null', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(null); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -195,28 +171,32 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns undefined when both multichain and EVM data are unavailable', () => { + it('falls back to EVM price when multichain data is undefined', () => { mockUseSelector - .mockReturnValueOnce({}) // selectTokenMarketData - empty - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - expect(result.current).toBeUndefined(); + expect(result.current).toBe(5.67); }); - }); - describe('EVM network scenarios', () => { - it('returns EVM percentage change when EVM network selected and state2 disabled', () => { + it('uses EVM fallback when multichain data exists but P1D is missing', () => { + const multichainWithoutP1D = { + '0x1234567890abcdef1234567890abcdef12345678': { + marketData: { + pricePercentChange: { + // P1D missing + }, + }, + }, + }; + mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(multichainWithoutP1D); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -225,34 +205,28 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns undefined when EVM selected but no market data available', () => { + it('uses EVM fallback when multichain marketData is missing', () => { + const multichainWithoutMarketData = { + '0x1234567890abcdef1234567890abcdef12345678': { + // marketData missing + }, + }; + mockUseSelector - .mockReturnValueOnce({}) // selectTokenMarketData - empty - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(multichainWithoutMarketData); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - expect(result.current).toBeUndefined(); + expect(result.current).toBe(5.67); }); - it('returns undefined when EVM selected but chain data missing', () => { - const partialMarketData = { - '0x5': { - '0x1234567890abcdef1234567890abcdef12345678': { - pricePercentChange1d: 5.67, - }, - }, - }; - + it('returns undefined when both multichain and EVM data are unavailable', () => { mockUseSelector - .mockReturnValueOnce(partialMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -261,20 +235,20 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); - it('returns undefined when EVM selected but token data missing', () => { - const partialMarketData = { - '0x1': { - '0xother': { - pricePercentChange1d: 5.67, + it('returns undefined when multichain asset data missing for specific token', () => { + const partialMultichainRates = { + 'other-asset-address': { + marketData: { + pricePercentChange: { + P1D: 7.89, + }, }, }, }; mockUseSelector - .mockReturnValueOnce(partialMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce(partialMultichainRates); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -284,29 +258,23 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Non-EVM network scenarios (keyring-snaps)', () => { - it('returns multichain percentage change when EVM not selected and state2 disabled (keyring-snaps)', () => { + describe('EVM market data scenarios', () => { + it('returns EVM percentage change when multichain data not available', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - // This depends on keyring-snaps conditional compilation - // If not available, it might return undefined - expect([7.89, undefined]).toContain(result.current); + expect(result.current).toBe(5.67); }); - it('returns undefined when EVM not selected and no multichain data available', () => { + it('returns undefined when no market data available', () => { mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -315,22 +283,38 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); - it('returns undefined when EVM not selected and multichain asset data missing', () => { - const partialMultichainRates = { - 'other-asset-address': { - marketData: { - pricePercentChange: { - P1D: 7.89, - }, + it('returns undefined when chain data missing', () => { + const partialMarketData = { + '0x5': { + '0x1234567890abcdef1234567890abcdef12345678': { + pricePercentChange1d: 5.67, }, }, }; mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(partialMultichainRates); // selectMultichainAssetsRates + .mockReturnValueOnce(partialMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + const { result } = renderHook(() => + useTokenPricePercentageChange(mockToken), + ); + + expect(result.current).toBeUndefined(); + }); + + it('returns undefined when token data missing for chain', () => { + const partialMarketData = { + '0x1': { + '0xother': { + pricePercentChange1d: 5.67, + }, + }, + }; + + mockUseSelector + .mockReturnValueOnce(partialMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -344,9 +328,7 @@ describe('useTokenPricePercentageChange', () => { it('handles null multichain market data', () => { mockUseSelector .mockReturnValueOnce(null) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -358,9 +340,7 @@ describe('useTokenPricePercentageChange', () => { it('handles undefined multichain market data', () => { mockUseSelector .mockReturnValueOnce(undefined) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -374,9 +354,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(tokenWithoutChainId), @@ -393,16 +371,12 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(nativeTokenWithoutChainId), ); - // When chainId is undefined, the chain lookup fails so getNativeTokenAddress might not be called - // The result should be undefined since there's no valid chainId to look up expect(result.current).toBeUndefined(); }); @@ -417,9 +391,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithZero) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -439,9 +411,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithNegative) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -451,67 +421,6 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Data prioritization and fallbacks', () => { - it('prioritizes multichain data over EVM when state2 enabled', () => { - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData (has 5.67) - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates (has 7.89) - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - // Should return multichain data (7.89) not EVM data (5.67) - expect(result.current).toBe(7.89); - }); - - it('uses EVM fallback when multichain data exists but P1D is missing', () => { - const multichainWithoutP1D = { - '0x1234567890abcdef1234567890abcdef12345678': { - marketData: { - pricePercentChange: { - // P1D missing - }, - }, - }, - }; - - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(multichainWithoutP1D); // selectMultichainAssetsRates - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - expect(result.current).toBe(5.67); // Falls back to EVM data - }); - - it('uses EVM fallback when multichain marketData is missing', () => { - const multichainWithoutMarketData = { - '0x1234567890abcdef1234567890abcdef12345678': { - // marketData missing - }, - }; - - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(multichainWithoutMarketData); // selectMultichainAssetsRates - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - expect(result.current).toBe(5.67); // Falls back to EVM data - }); - }); - describe('Native token address resolution', () => { it('calls getNativeTokenAddress with correct chainId for native tokens', () => { const customChainNativeToken = { @@ -529,9 +438,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(customChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(customChainNativeToken), @@ -555,9 +462,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithCustomNative) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockNativeToken), @@ -565,26 +470,32 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(12.34); }); + + it('does not call getNativeTokenAddress for non-native tokens', () => { + mockUseSelector + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + renderHook(() => useTokenPricePercentageChange(mockToken)); + + expect(mockGetNativeTokenAddress).not.toHaveBeenCalled(); + }); }); describe('Selector call verification', () => { - it('calls all required selectors in correct order', () => { + it('calls both selectors', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates renderHook(() => useTokenPricePercentageChange(mockToken)); - expect(mockUseSelector).toHaveBeenCalledTimes(4); + expect(mockUseSelector).toHaveBeenCalledTimes(2); }); - it('handles all selectors returning null/undefined', () => { + it('handles all selectors returning null', () => { mockUseSelector .mockReturnValueOnce(null) // selectTokenMarketData - .mockReturnValueOnce(null) // selectIsEvmNetworkSelected - .mockReturnValueOnce(null) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(null); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -593,5 +504,17 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); + + it('handles all selectors returning undefined', () => { + mockUseSelector + .mockReturnValueOnce(undefined) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + const { result } = renderHook(() => + useTokenPricePercentageChange(mockToken), + ); + + expect(result.current).toBeUndefined(); + }); }); }); diff --git a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts index 409d40ab74e2..996d8ff817b9 100644 --- a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts +++ b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts @@ -1,11 +1,9 @@ import { useSelector } from 'react-redux'; import { TokenI } from '../types'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; -import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain/multichain'; import { CaipAssetType, Hex } from '@metamask/utils'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; /** * Returns the 1 day price percentage change for a given asset. @@ -16,10 +14,7 @@ export const useTokenPricePercentageChange = ( asset?: TokenI, ): number | undefined => { const multiChainMarketData = useSelector(selectTokenMarketData); - const isEvmNetworkSelected = useSelector(selectIsEvmNetworkSelected); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const allMultichainAssetsRates = useSelector(selectMultichainAssetsRates); ///: END:ONLY_INCLUDE_IF(keyring-snaps) @@ -34,17 +29,8 @@ export const useTokenPricePercentageChange = ( ]?.pricePercentChange1d : tokenPercentageChange; - if (isMultichainAccountsState2Enabled) { - return ( - allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData - ?.pricePercentChange?.P1D ?? evmPricePercentChange1d - ); - } - if (isEvmNetworkSelected) { - return evmPricePercentChange1d; - } - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - return allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData - ?.pricePercentChange?.P1D; - ///: END:ONLY_INCLUDE_IF(keyring-snaps) + return ( + allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData + ?.pricePercentChange?.P1D ?? evmPricePercentChange1d + ); }; diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 647c18f7a556..b4270383bbbc 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -20,7 +20,7 @@ jest.mock('react-native-device-info', () => ({ const selectedAddress = '0x123'; -jest.mock('./TokensBottomSheet', () => ({ +jest.mock('./TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['BottomSheetScreen', {}]), })); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index a3288332f60f..719066ad3f85 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -17,7 +17,7 @@ import { selectNativeNetworkCurrencies, } from '../../../selectors/networkController'; import { getDecimalChainId } from '../../../util/networks'; -import { TokenList } from './TokenList'; +import { TokenList } from './TokenList/TokenList'; import { TokenI } from './types'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import { strings } from '../../../../locales/i18n'; @@ -30,12 +30,10 @@ import { import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { Box } from '@metamask/design-system-react-native'; -import { TokenListControlBar } from './TokenListControlBar'; +import { TokenListControlBar } from './TokenListControlBar/TokenListControlBar'; import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; -import { ScamWarningModal } from './TokenList/ScamWarningModal'; -import TokenListSkeleton from './TokenList/TokenListSkeleton'; -import { selectSortedTokenKeys } from '../../../selectors/tokenList'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; +import { ScamWarningModal } from './TokenList/ScamWarningModal/ScamWarningModal'; +import TokenListSkeleton from './TokenList/TokenListSkeleton/TokenListSkeleton'; import { selectSortedAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { SolScope } from '@metamask/keyring-api'; @@ -97,21 +95,8 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { const [showScamWarningModal, setShowScamWarningModal] = useState(false); const [hasInitialLoad, setHasInitialLoad] = useState(false); - // BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - // Memoize selector computation for better performance - const sortedTokenKeys = useSelector( - useMemo( - () => - isMultichainAccountsState2Enabled - ? selectSortedAssetsBySelectedAccountGroup - : selectSortedTokenKeys, - [isMultichainAccountsState2Enabled], - ), - ); + const sortedTokenKeys = useSelector(selectSortedAssetsBySelectedAccountGroup); // Mark as loaded once we have data (even if empty) useEffect(() => { @@ -245,12 +230,10 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { )} - {showScamWarningModal && ( - - )} + } title={strings('wallet.remove_token_title')} diff --git a/app/components/UI/Tokens/styles.ts b/app/components/UI/Tokens/styles.ts deleted file mode 100644 index 2632e0082a30..000000000000 --- a/app/components/UI/Tokens/styles.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { fontStyles } from '../../../styles/common'; -import { Colors } from 'app/util/theme/models'; - -const createStyles = (colors: Colors) => - StyleSheet.create({ - bottomSheetTitle: { - alignSelf: 'center', - paddingTop: 16, - paddingBottom: 16, - }, - bottomSheetText: { - width: '100%', - }, - balances: { - flex: 1, - justifyContent: 'center', - marginLeft: 20, - }, - balanceFiat: { - color: colors.text.alternative, - ...fontStyles.normal, - textTransform: 'uppercase', - }, - ethLogo: { - width: 40, - height: 40, - borderRadius: 20, - overflow: 'hidden', - }, - buy: { - alignItems: 'center', - marginVertical: 5, - marginHorizontal: 15, - }, - buyTitle: { - marginVertical: 5, - textAlign: 'center', - }, - buyButton: { - marginVertical: 5, - }, - assetName: { - flexDirection: 'row', - gap: 8, - }, - percentageChange: { - flexDirection: 'row', - alignItems: 'center', - alignContent: 'center', - }, - stakeButton: { - flexDirection: 'row', - }, - dot: { - marginLeft: 2, - marginRight: 2, - }, - portfolioBalance: { - marginHorizontal: 16, - }, - bottomModal: { - justifyContent: 'flex-end', - margin: 0, - }, - box: { - backgroundColor: colors.background.default, - paddingHorizontal: 8, - paddingBottom: 20, - borderWidth: 0, - padding: 0, - }, - boxContent: { - backgroundColor: colors.background.default, - paddingBottom: 21, - paddingTop: 0, - borderWidth: 0, - }, - editNetworkButton: { - width: '100%', - }, - notch: { - width: 40, - height: 4, - borderRadius: 2, - backgroundColor: colors.border.muted, - alignSelf: 'center', - marginTop: 4, - }, - controlIconButton: { - backgroundColor: colors.background.default, - }, - balanceContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - loaderWrapper: { - flexDirection: 'column', - gap: 4, - }, - networkImageContainer: { - position: 'absolute', - right: 0, - }, - badge: { - marginTop: 8, - }, - wrapperSkeleton: { - backgroundColor: colors.background.default, - }, - skeletonItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - }, - skeletonTextContainer: { - flex: 1, - marginLeft: 12, - }, - skeletonValueContainer: { - alignItems: 'flex-end', - }, - }); - -export default createStyles; diff --git a/app/components/UI/Tokens/util/filterAssets.test.ts b/app/components/UI/Tokens/util/filterAssets.test.ts deleted file mode 100644 index 4c23fe54815b..000000000000 --- a/app/components/UI/Tokens/util/filterAssets.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { filterAssets, FilterCriteria } from './filterAssets'; - -describe('filterAssets function', () => { - interface MockToken { - name: string; - symbol: string; - chainId: string; - balance: number; - } - - const mockTokens: MockToken[] = [ - { name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 }, - { name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 }, - { name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 }, - { name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 }, - ]; - - test('returns all assets if no criteria are provided', () => { - const criteria: FilterCriteria[] = []; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // No filtering occurs - }); - - test('returns all assets if filterCallback is undefined', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, // Valid opts - filterCallback: undefined as unknown as 'inclusive', // Undefined callback - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // No filtering occurs due to missing filterCallback - }); - - test('filters by inclusive chainId', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(3); - expect(filtered.map((token) => token.chainId)).toEqual([ - '0x01', - '0x01', - '0x89', - ]); - }); - - test('filters tokens with balance between 100 and 150 inclusive', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(2); // Token1 and Token4 - expect(filtered.map((token) => token.balance)).toEqual([100, 150]); - }); - - test('filters by inclusive chainId and balance range', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, - filterCallback: 'inclusive', - }, - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(2); // Token1 and Token4 - }); - - test('returns no tokens if no chainId matches', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x04': true }, - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No matching tokens - }); - - test('returns no tokens if balance is not within range', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 300, max: 400 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No matching tokens - }); - - test('handles empty opts in inclusive callback', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: {}, // Empty opts - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No tokens match empty opts - }); - - test('handles invalid range opts', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: undefined, max: undefined } as unknown as { - min: number; - max: number; - }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No tokens match invalid range - }); - - test('handles missing values in assets gracefully', () => { - const incompleteTokens = [ - { name: 'Token1', symbol: 'T1', chainId: '0x01' }, // Missing balance - ]; - - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(incompleteTokens, criteria); - - expect(filtered).toHaveLength(0); // Incomplete token doesn't match - }); - - test('ignores unknown filterCallback types', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'unknown' as unknown as 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // Unknown callback doesn't filter - }); -}); diff --git a/app/components/UI/Tokens/util/filterAssets.ts b/app/components/UI/Tokens/util/filterAssets.ts deleted file mode 100644 index 7d201831b57b..000000000000 --- a/app/components/UI/Tokens/util/filterAssets.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { get } from 'lodash'; - -export interface FilterCriteria { - key: string; - opts: Record; // Use opts for range, inclusion, etc. - filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc. -} - -export type FilterType = string | number | boolean | Date; -type FilterCallbackKeys = keyof FilterCallbacksT; - -export interface FilterCallbacksT { - inclusive: (value: string, opts: Record) => boolean; - range: (value: number, opts: Record) => boolean; -} - -/** - * A collection of filter callback functions used for various filtering operations. - */ -const filterCallbacks: FilterCallbacksT = { - /** - * Checks if a given value exists as a key in the provided options object - * and returns its corresponding boolean value. - * - * @param value - The key to check in the options object. - * @param opts - A record object containing boolean values for keys. - * @returns `false` if the options object is empty, otherwise returns the boolean value associated with the key. - */ - inclusive: (value: string, opts: Record) => { - if (Object.entries(opts).length === 0) { - return false; - } - return opts[value]; - }, - /** - * Checks if a given numeric value falls within a specified range. - * - * @param value - The number to check. - * @param opts - A record object with `min` and `max` properties defining the range. - * @returns `true` if the value is within the range [opts.min, opts.max], otherwise `false`. - */ - range: (value: number, opts: Record) => - value >= opts.min && value <= opts.max, -}; - -function getNestedValue(obj: T, keyPath: string): FilterType { - return get(obj, keyPath); -} - -/** - * Filters an array of assets based on a set of criteria. - * - * @template T - The type of the assets in the array. - * @param assets - The array of assets to be filtered. - * @param criteria - An array of filter criteria objects. Each criterion contains: - * - `key`: A string representing the key to be accessed within the asset (supports nested keys). - * - `opts`: An object specifying the options for the filter. The structure depends on the `filterCallback` type. - * - `filterCallback`: The filtering method to apply, such as `'inclusive'` or `'range'`. - * @returns A new array of assets that match all the specified criteria. - */ -export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { - if (criteria.length === 0) { - return assets; - } - - return assets.filter((asset) => - criteria.every(({ key, opts, filterCallback }) => { - const nestedValue = getNestedValue(asset, key); - - // If there's no callback or options, exit early and don't filter based on this criterion. - if (!filterCallback || !opts) { - return true; - } - - switch (filterCallback) { - case 'inclusive': - return filterCallbacks.inclusive( - nestedValue as string, - opts as Record, - ); - case 'range': - return filterCallbacks.range( - nestedValue as number, - opts as { min: number; max: number }, - ); - default: - return true; - } - }), - ); -} diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx new file mode 100644 index 000000000000..73ac63fff051 --- /dev/null +++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx @@ -0,0 +1,523 @@ +// Mock Text component from component-library FIRST (before any imports that use it) +jest.mock('../../../component-library/components/Texts/Text', () => { + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { + children: React.ReactNode; + variant?: string; + style?: unknown; + }) => {props.children}, + TextVariant: { + HeadingLG: 'HeadingLG', + BodyMD: 'BodyMD', + }, + TextColor: { + Default: 'Default', + Inverse: 'Inverse', + Alternative: 'Alternative', + Muted: 'Muted', + Primary: 'Primary', + PrimaryAlternative: 'PrimaryAlternative', + Success: 'Success', + Error: 'Error', + ErrorAlternative: 'ErrorAlternative', + Warning: 'Warning', + Info: 'Info', + }, + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import TurnOffRememberMeModal from './TurnOffRememberMeModal'; +import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../constants/storage'; + +// Mock Authentication +jest.mock('../../../core', () => ({ + Authentication: { + updateAuthPreference: jest.fn(), + lockApp: jest.fn(), + }, +})); + +// Mock StorageWrapper +jest.mock('../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock doesPasswordMatch +jest.mock('../../../util/password', () => ({ + doesPasswordMatch: jest.fn(), +})); + +// Mock OutlinedTextField +jest.mock('react-native-material-textfield', () => { + const ReactActual = jest.requireActual('react'); + const { TextInput } = jest.requireActual('react-native'); + return { + OutlinedTextField: ReactActual.forwardRef( + ( + { + placeholder, + value, + onChangeText, + editable, + secureTextEntry, + ...props + }: { + placeholder?: string; + value?: string; + onChangeText?: (text: string) => void; + editable?: boolean; + secureTextEntry?: boolean; + [key: string]: unknown; + }, + ref: unknown, + ) => ( + } + placeholder={placeholder} + value={value} + onChangeText={onChangeText} + editable={editable} + secureTextEntry={secureTextEntry} + testID={ + placeholder ? `text-input-${placeholder}` : 'outlined-text-field' + } + {...props} + /> + ), + ), + }; +}); + +// Mock ReusableModal +const mockDismissModal = jest.fn(); +jest.mock('../ReusableModal', () => { + const ReactActual = jest.requireActual('react'); + const { View: RNView } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { children }: { children: React.ReactNode; isInteractable?: boolean }, + ref: React.Ref<{ dismissModal: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + dismissModal: mockDismissModal, + })); + return {children}; + }, + ); +}); + +// Mock useTheme +jest.mock('../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + primary: { default: '#0000ff' }, + border: { default: '#cccccc' }, + text: { muted: '#999999' }, + }, + themeAppearance: 'light', + }), +})); + +// Mock styles +jest.mock('./styles', () => ({ + createStyles: () => ({ + container: {}, + areYouSure: {}, + textStyle: {}, + input: {}, + }), +})); + +// Mock Box from design-system-react-native +jest.mock('@metamask/design-system-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { + Box: View, + BoxFlexDirection: { + Row: 'row', + }, + BoxAlignItems: { + Center: 'center', + }, + }; +}); + +// Mock strings/i18n +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + +// Mock WarningExistingUserModal +jest.mock('../WarningExistingUserModal', () => { + const { View: RNView, TouchableOpacity: RNTouchableOpacity } = + jest.requireActual('react-native'); + return ({ + children, + cancelText, + cancelButtonDisabled, + onCancelPress, + onRequestClose, + onConfirmPress, + warningModalVisible, + }: { + children: React.ReactNode; + cancelText: string; + cancelButtonDisabled: boolean; + onCancelPress: () => void; + onRequestClose: () => void; + onConfirmPress: () => void; + warningModalVisible: boolean; + }) => { + if (!warningModalVisible) return null; + return ( + + {children} + + {cancelText} + + + + + ); + }; +}); + +describe('TurnOffRememberMeModal', () => { + let mockDoesPasswordMatch: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockLockApp: jest.Mock; + let mockGetItem: jest.Mock; + let mockRemoveItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Get the mocked functions from the modules + const passwordModule = jest.requireMock('../../../util/password'); + mockDoesPasswordMatch = passwordModule.doesPasswordMatch as jest.Mock; + + const coreModule = jest.requireMock('../../../core'); + mockUpdateAuthPreference = coreModule.Authentication + .updateAuthPreference as jest.Mock; + mockLockApp = coreModule.Authentication.lockApp as jest.Mock; + + const storageModule = jest.requireMock('../../../store/storage-wrapper'); + mockGetItem = storageModule.default.getItem as jest.Mock; + mockRemoveItem = storageModule.default.removeItem as jest.Mock; + + // Clear and reset mockDismissModal + mockDismissModal.mockClear(); + + // Set default mock implementations + mockDoesPasswordMatch.mockResolvedValue({ valid: false }); + mockUpdateAuthPreference.mockResolvedValue(undefined); + mockLockApp.mockResolvedValue(undefined); + mockGetItem.mockResolvedValue(null); + mockRemoveItem.mockResolvedValue(undefined); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + it('renders correctly', () => { + const { getByText, getByTestId } = renderWithProvider( + , + { + state: initialState, + }, + ); + + expect(getByTestId('reusable-modal')).toBeTruthy(); + expect(getByTestId('warning-existing-user-modal')).toBeTruthy(); + expect(getByText('turn_off_remember_me.title')).toBeTruthy(); + }); + + it('disables button when password is invalid', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: false }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.changeText(input, 'invalid'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + expect(button.props.disabled).toBe(true); + }); + }); + + it('enables button when password is valid', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + expect(button.props.disabled).toBe(false); + }); + }); + + it('restores previous auth type when disabling remember me', async () => { + mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'ValidPassword123!', + ); + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); + + it('falls back to PASSWORD when no previous auth type is stored', async () => { + mockGetItem.mockResolvedValue(null); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + 'ValidPassword123!', + ); + }); + }); + + it('shows loading indicator during password submission', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect( + queryByTestId('text-input-turn_off_remember_me.placeholder'), + ).toBeNull(); + expect(button.props.disabled).toBe(true); + + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockLockApp).toHaveBeenCalled(); + }); + } + }); + + it('disables input and button during loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect( + queryByTestId('text-input-turn_off_remember_me.placeholder'), + ).toBeNull(); + expect(button.props.disabled).toBe(true); + + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockLockApp).toHaveBeenCalled(); + }); + } + }); + + it('handles error during auth preference update', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValue(error); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); + + it('prevents modal dismissal during loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect(mockDismissModal).not.toHaveBeenCalled(); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockDismissModal).toHaveBeenCalled(); + }); + } + }); + + it('clears loading state after operation completes', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx index b0131fd4402b..57973f5e8ab1 100644 --- a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx +++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx @@ -4,6 +4,7 @@ import { TouchableWithoutFeedback, Keyboard, SafeAreaView, + ActivityIndicator, } from 'react-native'; import Text, { TextVariant, @@ -20,6 +21,15 @@ import { doesPasswordMatch } from '../../../util/password'; import { setAllowLoginWithRememberMe } from '../../../actions/security'; import { useDispatch } from 'react-redux'; import { Authentication } from '../../../core'; +import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import Logger from '../../../util/Logger'; +import StorageWrapper from '../../../store/storage-wrapper'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../constants/storage'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; export const createTurnOffRememberMeModalNavDetails = createNavigationDetails( Routes.MODAL.ROOT_MODAL_FLOW, @@ -35,6 +45,7 @@ const TurnOffRememberMeModal = () => { const [passwordText, setPasswordText] = useState(''); const [disableButton, setDisableButton] = useState(true); + const [isLoading, setIsLoading] = useState(false); const isValidPassword = useCallback( async (text: string): Promise => { @@ -60,24 +71,66 @@ const TurnOffRememberMeModal = () => { const dismissModal = (cb?: () => void): void => modalRef?.current?.dismissModal(cb); - const triggerClose = () => dismissModal(); + const triggerClose = () => { + if (!isLoading) { + dismissModal(); + } + }; const turnOffRememberMeAndLockApp = useCallback(async () => { - dispatch(setAllowLoginWithRememberMe(false)); - Authentication.lockApp(); - }, [dispatch]); + setIsLoading(true); + try { + // Get the previous auth type that was stored before enabling remember me + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Determine which auth method to restore + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + // Use the password entered in the modal to restore auth method + await Authentication.updateAuthPreference( + authTypeToRestore, + passwordText, + ); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem(PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(false)); + Authentication.lockApp(); + // Dismiss modal after successful operation + dismissModal(); + } catch (error) { + // If update fails, still disable remember me and lock app + // The user will need to re-enable their preferred auth method + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + error as Error, + 'Failed to restore auth preference when disabling remember me', + ); + Authentication.lockApp(); + // Dismiss modal even on error + dismissModal(); + } finally { + setIsLoading(false); + } + }, [dispatch, passwordText]); const disableRememberMe = useCallback(async () => { - dismissModal(async () => await turnOffRememberMeAndLockApp()); + // Don't dismiss modal here - let turnOffRememberMeAndLockApp handle it + await turnOffRememberMeAndLockApp(); }, [turnOffRememberMeAndLockApp]); return ( - + { {strings('turn_off_remember_me.description')} - + {isLoading ? ( + + + + ) : ( + + )} diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx index 96104b9a0cd8..59c64c163628 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx @@ -38,8 +38,7 @@ jest.mock('../../../hooks/useStyles', () => ({ useStyles: jest.fn(), })); -jest.mock('../../Tokens/TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: jest.fn(() => ['TokenFilter', {}]), +jest.mock('../../Tokens/TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['TokensBottomSheet', {}]), })); diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx index d2a37b243fec..50199ec22746 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx @@ -19,7 +19,7 @@ import { IconName } from '../../../../component-library/components/Icons/Icon'; import { selectNetworkName } from '../../../../selectors/networkInfos'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { getNetworkImageSource } from '../../../../util/networks'; -import { createTokensBottomSheetNavDetails } from '../../Tokens/TokensBottomSheet'; +import { createTokensBottomSheetNavDetails } from '../../Tokens/TokenSortBottomSheet/TokenSortBottomSheet'; import { createNetworkManagerNavDetails } from '../../NetworkManager'; import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { diff --git a/app/components/Views/AccountSelector/AccountSelector.test.tsx b/app/components/Views/AccountSelector/AccountSelector.test.tsx index a431b6545e37..cad23997691a 100644 --- a/app/components/Views/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.test.tsx @@ -20,14 +20,14 @@ import { internalSolanaAccount1, } from '../../../util/test/accountsControllerTestUtils'; -jest.mock('../../hooks/useFeatureFlag', () => ({ - useFeatureFlag: jest.fn(() => false), // Default to BottomSheet version for tests - FeatureFlagNames: { - rewardsEnabled: 'rewardsEnabled', - otaUpdatesEnabled: 'otaUpdatesEnabled', - fullPageAccountList: 'fullPageAccountList', - }, -})); +const mockSelectFullPageAccountListEnabledFlag = jest.fn(() => false); +jest.mock( + '../../../selectors/featureFlagController/fullPageAccountList', + () => ({ + selectFullPageAccountListEnabledFlag: () => + mockSelectFullPageAccountListEnabledFlag(), + }), +); const mockAvatarAccountType = 'Maskicon' as const; @@ -670,17 +670,13 @@ describe('AccountSelector', () => { }); describe('Feature Flag: Full-Page Account List', () => { - let mockUseFeatureFlag: jest.Mock; - beforeEach(() => { jest.clearAllMocks(); - mockUseFeatureFlag = jest.requireMock( - '../../hooks/useFeatureFlag', - ).useFeatureFlag; + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); }); it('renders BottomSheet when feature flag is disabled', () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); renderScreen( AccountSelectorWrapper, @@ -702,7 +698,7 @@ describe('AccountSelector', () => { }); it('renders full-page modal when feature flag is enabled', () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); renderScreen( AccountSelectorWrapper, @@ -725,7 +721,7 @@ describe('AccountSelector', () => { it('renders add button in both modes', () => { // Arrange: BottomSheet mode - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); // Act: Render in BottomSheet mode const { unmount } = renderScreen( @@ -750,7 +746,7 @@ describe('AccountSelector', () => { // Arrange: Full-page mode jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); // Act: Render in full-page mode renderScreen( @@ -777,7 +773,7 @@ describe('AccountSelector', () => { it('switches between multichain screens in full-page mode', () => { // Arrange jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); renderScreen( @@ -806,7 +802,7 @@ describe('AccountSelector', () => { it('closes BottomSheet when account is selected with feature flag disabled', async () => { // Arrange - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); const { getAllByTestId } = renderScreen( AccountSelectorWrapper, @@ -840,7 +836,7 @@ describe('AccountSelector', () => { it('renders SheetHeader with title in full-page mode', () => { // Arrange - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); renderScreen( AccountSelectorWrapper, @@ -864,7 +860,7 @@ describe('AccountSelector', () => { it('closes full-page modal when account is selected with feature flag enabled', async () => { // Arrange jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); // Mock the useNavigation hook to prevent navigation warnings const mockGoBack = jest.fn(); diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index f78ffd33468e..31684e544b7c 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -36,7 +36,7 @@ import BottomSheet, { import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import Engine from '../../../core/Engine'; -import { useFeatureFlag, FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { selectFullPageAccountListEnabledFlag } from '../../../selectors/featureFlagController/fullPageAccountList'; import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; @@ -95,8 +95,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const routeParams = useMemo(() => route?.params, [route?.params]); // Feature flag for full-page account list - const isFullPageAccountList = useFeatureFlag( - FeatureFlagNames.fullPageAccountList, + const isFullPageAccountList = useSelector( + selectFullPageAccountListEnabledFlag, ); const sheetRef = useRef(null); diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx index 8ece0d1a6508..88668afe83e2 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx @@ -10,7 +10,7 @@ import { FeatureFlagInfo, isMinimumRequiredVersionSupported, } from '../../../util/feature-flags'; -import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; // Mock all dependencies jest.mock('@react-navigation/native', () => ({ diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx index ff31fda0f3ea..67579950a467 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx @@ -23,7 +23,7 @@ import { } from '../../../util/feature-flags'; import { useFeatureFlagOverride } from '../../../contexts/FeatureFlagOverrideContext'; import { useFeatureFlagStats } from '../../../hooks/useFeatureFlagStats'; -import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; interface FeatureFlagRowProps { flag: FeatureFlagInfo; diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx index d761c2eeb785..948e1b492ef1 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx @@ -26,6 +26,9 @@ import { endTrace, } from '../../../util/trace'; import type { Span } from '@sentry/core'; +import ReduxService from '../../../core/redux/ReduxService'; +import { RootState } from '../../../reducers'; +import { ReduxStore } from '../../../core/redux/types'; jest.mock('react-native/Libraries/Components/Keyboard/Keyboard', () => ({ dismiss: jest.fn(), @@ -87,12 +90,45 @@ jest.mock('../../hooks/useMetrics', () => { }); describe('ImportFromSecretRecoveryPhrase', () => { + const createMockReduxStore = ( + stateOverrides?: Partial, + ): ReduxStore => { + const defaultState = { + user: { + existingUser: false, + passwordSet: true, + seedphraseBackedUp: false, + }, + security: { + allowLoginWithRememberMe: false, + }, + settings: { + lockTime: -1, + }, + ...(stateOverrides || {}), + } as RootState; + + return { + dispatch: jest.fn(), + getState: jest.fn(() => defaultState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore; + }; + afterEach(() => { jest.clearAllMocks(); + // Restore Redux store mock after clearing mocks + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); beforeEach(() => { jest.clearAllMocks(); + // Mock Redux store for all tests + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); jest diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index 3e3cd506eca4..04c234921c29 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -268,30 +268,16 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { if (backupResult.vault) { const vaultSeed = await parseVaultValue(password, backupResult.vault); if (vaultSeed) { - // get authType - const authData = await Authentication.componentAuthenticationType( - biometryChoice, - rememberMe, + navigation.replace( + ...createRestoreWalletNavDetailsNested({ + previousScreen: Routes.ONBOARDING.LOGIN, + }), ); - try { - await Authentication.storePassword( - password, - authData.currentAuthType, - ); - navigation.replace( - ...createRestoreWalletNavDetailsNested({ - previousScreen: Routes.ONBOARDING.LOGIN, - }), - ); - setLoading(false); - setError(null); - return; - } catch (e) { - throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} ${e}`); - } - } else { - throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} Invalid Password`); + setLoading(false); + setError(null); + return; } + throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} Invalid Password`); } else if (backupResult.error) { throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} ${backupResult.error}`); } @@ -308,7 +294,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { setError(strings('login.invalid_password')); } - }, [password, biometryChoice, rememberMe, navigation]); + }, [password, navigation]); const navigateToHome = useCallback(async () => { navigation.replace(Routes.ONBOARDING.HOME_NAV); diff --git a/app/components/Views/Login/index2.test.tsx b/app/components/Views/Login/index2.test.tsx index cf88fca19dfa..79fcdac944aa 100644 --- a/app/components/Views/Login/index2.test.tsx +++ b/app/components/Views/Login/index2.test.tsx @@ -130,6 +130,31 @@ jest.mock('../../../multichain-accounts/remote-feature-flag', () => ({ })); describe('Login test suite 2', () => { + const createMockReduxStore = ( + stateOverrides?: RecursivePartial, + ) => { + const defaultState = { + user: { + existingUser: false, + }, + security: { + allowLoginWithRememberMe: false, + }, + settings: { + lockTime: -1, + }, + ...(stateOverrides || {}), + } as RecursivePartial; + + return { + dispatch: jest.fn(), + getState: jest.fn(() => defaultState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore; + }; + beforeAll(() => { jest.useFakeTimers(); }); @@ -138,12 +163,19 @@ describe('Login test suite 2', () => { jest .spyOn(Authentication, 'checkIsSeedlessPasswordOutdated') .mockResolvedValue(false); + + // Mock Redux store for all tests + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.clearAllTimers(); jest.clearAllMocks(); + // Restore Redux store mock after clearing mocks + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); afterAll(() => { @@ -183,7 +215,7 @@ describe('Login test suite 2', () => { }); jest - .spyOn(Authentication, 'storePassword') + .spyOn(Authentication, 'updateAuthPreference') .mockResolvedValueOnce(undefined); const { getByTestId } = renderWithProvider(); @@ -276,21 +308,11 @@ describe('Login test suite 2', () => { .spyOn(Authentication, 'userEntryAuth') .mockRejectedValue(new Error(VAULT_ERROR)); + // Mock getVaultFromBackup to return an error to trigger error handling mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', + success: false, + error: 'Store password failed', }); - mockParseVaultValue.mockResolvedValueOnce('mock-seed'); - - jest - .spyOn(Authentication, 'componentAuthenticationType') - .mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - - jest - .spyOn(Authentication, 'storePassword') - .mockRejectedValueOnce(new Error('Store password failed')); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -302,7 +324,9 @@ describe('Login test suite 2', () => { fireEvent(passwordInput, 'submitEditing'); }); - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + await waitFor(() => { + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + }); }); it('handle vault corruption when vault seed cannot be parsed', async () => { @@ -380,6 +404,12 @@ describe('Login test suite 2', () => { return null; }); const mockState: RecursivePartial = { + user: { + existingUser: false, + }, + security: { + allowLoginWithRememberMe: false, + }, engine: { backgroundState: { SeedlessOnboardingController: { @@ -392,8 +422,14 @@ describe('Login test suite 2', () => { jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ dispatch: jest.fn(), getState: jest.fn(() => mockState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), } as unknown as ReduxStore); - jest.spyOn(Authentication, 'storePassword').mockResolvedValue(undefined); + jest.spyOn(Authentication, 'userEntryAuth').mockResolvedValue(undefined); + jest + .spyOn(Authentication, 'updateAuthPreference') + .mockResolvedValue(undefined); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); diff --git a/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx b/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx index 25097cff25f5..27e0abfcb86a 100644 --- a/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx @@ -7,6 +7,8 @@ import { strings } from '../../../../../../locales/i18n'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { SHEET_HEADER_BACK_BUTTON_ID } from '../../../../../component-library/components/Sheet/SheetHeader/SheetHeader.constants'; +import ReduxService from '../../../../../core/redux/ReduxService'; +import { ReduxStore } from '../../../../../core/redux/types'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); @@ -74,6 +76,22 @@ const render = () => { describe('RevealPrivateKey', () => { beforeEach(() => { jest.clearAllMocks(); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: jest.fn(), + getState: () => ({ + user: { existingUser: false }, + security: { allowLoginWithRememberMe: true }, + settings: { lockTime: 1000 }, + }), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('renders correctly with account information', () => { diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx new file mode 100644 index 000000000000..1c3ad8ffc848 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx @@ -0,0 +1,819 @@ +// Mock StorageWrapper FIRST (before any imports that use it) +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock Authentication +jest.mock('../../../../../core', () => ({ + Authentication: { + getType: jest.fn(), + updateAuthPreference: jest.fn(), + }, +})); + +// Mock navigation - define navigate function that can be accessed +const mockNavigateFn = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigateFn, + }), + }; +}); + +// Mock useTheme +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + primary: { default: '#0376C9' }, + background: { default: '#FFFFFF' }, + text: { default: '#000000' }, + }, + }), +})); + +// Mock createStyles +jest.mock('../SecuritySettings.styles', () => ({ + __esModule: true, + default: () => ({ + setting: {}, + }), +})); + +// Mock Box and other design system components +jest.mock('@metamask/design-system-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { + Box: ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + [key: string]: unknown; + }) => ( + + {children} + + ), + BoxFlexDirection: { Row: 'row', Column: 'column' }, + BoxAlignItems: { Center: 'center' }, + }; +}); + +// Mock SecurityOptionToggle +jest.mock('../../../../UI/SecurityOptionToggle', () => { + const { Switch } = jest.requireActual('react-native'); + return { + SecurityOptionToggle: ({ + testId, + value, + onOptionUpdated, + disabled, + }: { + testId: string; + value: boolean; + onOptionUpdated: (val: boolean) => void; + disabled?: boolean; + }) => ( + + ), + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import LoginOptionsSettings from './LoginOptionsSettings'; +import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; +import { SecurityPrivacyViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/SecurityAndPrivacy/SecurityPrivacyView.selectors'; + +// Mock Device +jest.mock('../../../../../util/device', () => ({ + isAndroid: jest.fn(() => false), + isIos: jest.fn(() => true), +})); + +// Mock Logger +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +// Import the actual constant +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; + +// Mock AuthenticationError as a proper class for instanceof to work +// Must be defined inside the factory because jest.mock is hoisted +jest.mock('../../../../../core/Authentication/AuthenticationError', () => { + class AuthenticationError extends Error { + customErrorMessage: string; + constructor(message: string, code: string) { + super(message); + this.customErrorMessage = code; + this.name = 'AuthenticationError'; + } + } + return { + __esModule: true, + default: AuthenticationError, + }; +}); + +// Get the mocked AuthenticationError class +const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', +).default as new ( + message: string, + code: string, +) => Error & { customErrorMessage: string }; + +describe('LoginOptionsSettings', () => { + let mockGetType: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockGetItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Get the mocked functions from the modules + const coreModule = jest.requireMock('../../../../../core'); + mockGetType = coreModule.Authentication.getType as jest.Mock; + mockUpdateAuthPreference = coreModule.Authentication + .updateAuthPreference as jest.Mock; + + const storageModule = jest.requireMock( + '../../../../../store/storage-wrapper', + ); + mockGetItem = storageModule.default.getItem as jest.Mock; + + // Set default mock implementations + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockResolvedValue(undefined); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: false, + }, + }; + + it('renders correctly', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect( + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ).toBeTruthy(); + }); + }); + + it('enables biometrics when toggle is turned on', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + }); + }); + + it('disables biometrics when toggle is turned off', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, + availableBiometryType: 'FaceID', + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + ); + }); + }); + + it('navigates to password entry when password is required for biometrics', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Simulate password entry + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('clears loading state when user cancels password entry', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Loading should be cleared in finally block even if callback is never called + // This is tested by ensuring the component doesn't get stuck in loading state + await waitFor(() => { + // Component should be interactive again + expect(toggle).toBeTruthy(); + }); + }); + + it('disables biometrics toggle when remember me is enabled', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + expect(toggle.props.disabled).toBe(true); + }); + + it('disables passcode toggle when remember me is enabled', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + expect(toggle.props.disabled).toBe(true); + }); + + it('disables passcode toggle when biometrics is loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const biometricToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(biometricToggle, 'onValueChange', true); + + // Wait for the passcode toggle to be disabled while biometrics is loading + await waitFor(() => { + const passcodeToggle = getByTestId( + SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE, + ); + expect(passcodeToggle.props.disabled).toBe(true); + }); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + // Loading should be cleared + }); + } + }); + + it('disables biometrics toggle when passcode is loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + const biometricToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + expect(biometricToggle.props.disabled).toBe(true); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + // Loading should be cleared + }); + } + }); + + it('handles error when updating auth preference fails', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + // Toggle should revert to original state on error + await waitFor(() => { + // Component should handle error gracefully + }); + }); + + it('reverts toggle state when password entry callback fails', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Simulate password entry that fails + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('navigates to password entry when password is required for passcode', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback for passcode', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + // Initial load: PASSWORD with FaceID available (shows passcode toggle) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // After password entry: PASSCODE + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // Mock getItem for the re-fetch after password entry + mockGetItem + .mockResolvedValueOnce(null) // Initial load + .mockResolvedValueOnce(null); // After password entry + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + // Wait for component to load and passcode toggle to appear + const passcodeToggle = await waitFor( + () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + { timeout: 3000 }, + ); + + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSCODE, + 'test-password', + ); + }); + } + }); + + it('reverts toggle state when passcode password entry callback fails', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSCODE, + 'test-password', + ); + }); + } + }); + + it('re-fetches auth type after successful password entry for biometrics', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // First call: initial load in useEffect + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // Second call: after password entry (re-fetch) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, + availableBiometryType: 'FaceID', + }); + + // Mock getItem for initial load and re-fetch after password entry + mockGetItem + .mockResolvedValueOnce(null) // Initial load + .mockResolvedValueOnce(null); // After password entry (re-fetch) + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor( + () => { + // Should re-fetch auth type after successful update + // Call 1: initial load in useEffect + // Call 2: re-fetch after password entry + expect(mockGetType).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 }, + ); + } + }); + + it('re-fetches auth type after successful password entry for passcode', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + // First call: initial load in useEffect + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // Second call: after password entry (re-fetch) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // Mock getItem for initial load (BIOMETRY_CHOICE_DISABLED and PASSCODE_DISABLED) + // and re-fetch after password entry (PASSCODE_DISABLED) + mockGetItem + .mockResolvedValueOnce(null) // Initial load: BIOMETRY_CHOICE_DISABLED + .mockResolvedValueOnce(null) // Initial load: PASSCODE_DISABLED + .mockResolvedValueOnce(null); // After password entry: PASSCODE_DISABLED + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + // Wait for component to load and passcode toggle to appear + const passcodeToggle = await waitFor( + () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + { timeout: 3000 }, + ); + + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor( + () => { + // Should re-fetch auth type after successful update + // Call 1: initial load in useEffect + // Call 2: re-fetch after password entry + expect(mockGetType).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 }, + ); + } + }); + + it('handles error when updating passcode auth preference fails', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx index a78fc9d76c4e..c74f09e4b009 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx @@ -12,25 +12,34 @@ import { PASSCODE_DISABLED, TRUE, } from '../../../../../constants/storage'; -import { View } from 'react-native'; +import { ActivityIndicator } from 'react-native'; import { LOGIN_OPTIONS } from '../SecuritySettings.constants'; import createStyles from '../SecuritySettings.styles'; import { SecurityPrivacyViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/SecurityAndPrivacy/SecurityPrivacyView.selectors'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Logger from '../../../../../util/Logger'; +import AuthenticationError from '../../../../../core/Authentication/AuthenticationError'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import { RootState } from '../../../../../reducers'; -interface BiometricOptionSectionProps { - onSignWithBiometricsOptionUpdated: (enabled: boolean) => Promise; - onSignWithPasscodeOptionUpdated: (enabled: boolean) => Promise; -} - -const LoginOptionsSettings = ({ - onSignWithBiometricsOptionUpdated, - onSignWithPasscodeOptionUpdated, -}: BiometricOptionSectionProps) => { +const LoginOptionsSettings = () => { + const navigation = useNavigation(); + const allowLoginWithRememberMe = useSelector( + (state: RootState) => state.security?.allowLoginWithRememberMe, + ); const [biometryType, setBiometryType] = useState< BIOMETRY_TYPE | AUTHENTICATION_TYPE.BIOMETRIC | undefined >(undefined); const [biometryChoice, setBiometryChoice] = useState(false); const [passcodeChoice, setPasscodeChoice] = useState(false); + const [isBiometricLoading, setIsBiometricLoading] = useState(false); + const [isPasscodeLoading, setIsPasscodeLoading] = useState(false); const { colors } = useTheme(); const styles = createStyles(colors); @@ -67,46 +76,220 @@ const LoginOptionsSettings = ({ const onBiometricsOptionUpdated = useCallback( async (enabled: boolean) => { - await onSignWithBiometricsOptionUpdated(enabled); - setBiometryChoice(enabled); + // Prevent toggling biometrics when remember me is enabled + if (allowLoginWithRememberMe) { + return; + } + + setIsBiometricLoading(true); + try { + const authType = enabled + ? AUTHENTICATION_TYPE.BIOMETRIC + : AUTHENTICATION_TYPE.PASSWORD; + + // Enabling biometrics is handled by the catch condition "isPasswordRequiredError" + await Authentication.updateAuthPreference(authType); + + // Only update UI if operation completed successfully + setBiometryChoice(enabled); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const authType = enabled + ? AUTHENTICATION_TYPE.BIOMETRIC + : AUTHENTICATION_TYPE.PASSWORD; + + navigation.navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + // Set loading back to true when callback is invoked + setIsBiometricLoading(true); + try { + await Authentication.updateAuthPreference( + authType, + enteredPassword, + ); + + // Update UI state after successful password entry and update + setBiometryChoice(enabled); + + // Re-fetch to ensure UI matches actual state + const currentAuthType = await Authentication.getType(); + const previouslyDisabled = await StorageWrapper.getItem( + BIOMETRY_CHOICE_DISABLED, + ); + setBiometryChoice( + currentAuthType.currentAuthType === + AUTHENTICATION_TYPE.BIOMETRIC && + !(previouslyDisabled && previouslyDisabled === TRUE), + ); + } catch (updateError) { + // On error, revert UI state + setBiometryChoice(!enabled); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } finally { + // Clear loading after callback completes + setIsBiometricLoading(false); + } + }, + }); + // Don't update UI state here - wait for callback + return; + } + // Other error - revert toggle state + Logger.error( + error as Error, + 'Failed to update auth preference after password entry', + ); + setBiometryChoice(!enabled); + } finally { + setIsBiometricLoading(false); + } }, - [onSignWithBiometricsOptionUpdated], + [navigation, allowLoginWithRememberMe], ); const onPasscodeOptionUpdated = useCallback( async (enabled: boolean) => { - await onSignWithPasscodeOptionUpdated(enabled); - setPasscodeChoice(enabled); + // Prevent toggling passcode when remember me is enabled + if (allowLoginWithRememberMe) { + return; + } + + setIsPasscodeLoading(true); + try { + const authType = enabled + ? AUTHENTICATION_TYPE.PASSCODE + : AUTHENTICATION_TYPE.PASSWORD; + + // Enabling passcode is handled by the catch condition "isPasswordRequiredError" + await Authentication.updateAuthPreference(authType); + + // Only update UI if operation completed successfully + setPasscodeChoice(enabled); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const authType = enabled + ? AUTHENTICATION_TYPE.PASSCODE + : AUTHENTICATION_TYPE.PASSWORD; + + navigation.navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + // Set loading back to true when callback is invoked + setIsPasscodeLoading(true); + try { + await Authentication.updateAuthPreference( + authType, + enteredPassword, + ); + + // Update UI state after successful password entry and update + setPasscodeChoice(enabled); + + // Re-fetch to ensure UI matches actual state + const currentAuthType = await Authentication.getType(); + const passcodePreviouslyDisabled = + await StorageWrapper.getItem(PASSCODE_DISABLED); + setPasscodeChoice( + currentAuthType.currentAuthType === + AUTHENTICATION_TYPE.PASSCODE && + !( + passcodePreviouslyDisabled && + passcodePreviouslyDisabled === TRUE + ), + ); + } catch (updateError) { + // On error, revert UI state + setPasscodeChoice(!enabled); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } finally { + // Clear loading after callback completes + setIsPasscodeLoading(false); + } + }, + }); + // Don't update UI state here - wait for callback + return; + } + // Other error - revert toggle state + Logger.error( + error as Error, + 'Failed to update auth preference after password entry', + ); + setPasscodeChoice(!enabled); + } finally { + setIsPasscodeLoading(false); + } }, - [onSignWithPasscodeOptionUpdated], + [navigation, allowLoginWithRememberMe], ); return ( - + {biometryType ? ( - - - + + {isBiometricLoading ? ( + + + + ) : ( + + )} + ) : null} {biometryType && !biometryChoice ? ( - - - + + {isPasscodeLoading ? ( + + + + ) : ( + + )} + ) : null} - + ); }; diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx new file mode 100644 index 000000000000..0300ab024b74 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx @@ -0,0 +1,832 @@ +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock locales/i18n to prevent it from using StorageWrapper during import +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +// Mock Authentication +jest.mock('../../../../../core', () => { + const mockGetTypeFn = jest.fn(); + const mockUpdateAuthPreferenceFn = jest.fn(); + return { + Authentication: { + getType: mockGetTypeFn, + updateAuthPreference: mockUpdateAuthPreferenceFn, + }, + __mockGetType: mockGetTypeFn, + __mockUpdateAuthPreference: mockUpdateAuthPreferenceFn, + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import RememberMeOptionSection from './RememberMeOptionSection'; +import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; +import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage'; +import Logger from '../../../../../util/Logger'; + +// Mock navigation +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +// Mock TurnOffRememberMeModal +jest.mock( + '../../../../UI/TurnOffRememberMeModal/TurnOffRememberMeModal', + () => ({ + createTurnOffRememberMeModalNavDetails: jest.fn(() => [ + 'TurnOffRememberMe', + {}, + ]), + }), +); + +// Mock AuthenticationError +jest.mock('../../../../../core/Authentication/AuthenticationError', () => { + class AuthenticationError extends Error { + customErrorMessage: string; + + constructor(message: string, code: string) { + super(message); + this.customErrorMessage = code; + this.name = 'AuthenticationError'; + } + } + + return { + __esModule: true, + default: AuthenticationError, + }; +}); + +// Mock Logger +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +describe('RememberMeOptionSection', () => { + let mockGetType: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockGetItem: jest.Mock; + let mockRemoveItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + const AuthenticationMock = jest.requireMock('../../../../../core'); + mockGetType = AuthenticationMock.__mockGetType; + mockUpdateAuthPreference = AuthenticationMock.__mockUpdateAuthPreference; + + // Get mocked StorageWrapper functions + const storageModule = jest.requireMock( + '../../../../../store/storage-wrapper', + ); + mockGetItem = storageModule.default.getItem as jest.Mock; + mockRemoveItem = storageModule.default.removeItem as jest.Mock; + + // Reset mocks to default behavior + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockUpdateAuthPreference.mockResolvedValue(undefined); + mockGetItem.mockResolvedValue(null); + mockRemoveItem.mockResolvedValue(undefined); + mockNavigate.mockClear(); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: false, + }, + }; + + it('renders correctly', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + expect(getByTestId(TURN_ON_REMEMBER_ME)).toBeTruthy(); + }); + + it('calls getType when attempting to disable remember me', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + }); + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + }); + + it('calls updateAuthPreference when enabling remember me', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + }); + }); + + it('does not call updateAuthPreference when disabling remember me', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + }); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + // Should navigate to turn off modal, not call updateAuthPreference + expect(mockNavigate).toHaveBeenCalled(); + }); + + expect(mockUpdateAuthPreference).not.toHaveBeenCalled(); + }); + + it('reverts flag if updateAuthPreference fails when enabling', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce(new Error('Update failed')); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + // The component should handle the error and revert the flag + // We verify updateAuthPreference was called and failed + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + }); + + it('displays correct toggle value based on Redux state', () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + expect(toggle.props.value).toBe(true); + }); + + it('navigates to password entry when password is required for enabling remember me', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + 'test-password', + ); + }); + } + }); + + it('reverts flag when password entry callback fails when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + 'test-password', + ); + }); + } + }); + + it('calls Logger.error when updateAuthPreference fails when enabling', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + error, + 'Failed to update auth preference for remember me', + ); + }); + }); + + it('calls Logger.error when password entry callback fails when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const updateError = new Error('Update failed'); + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(updateError); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + updateError, + 'Failed to update auth preference after password entry', + ); + }); + } + }); + + it('successfully disables remember me and restores password auth type', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + // Wait for getType to be called (from onValueChanged) + await waitFor( + () => { + expect(mockGetType).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + + // Wait for getItem to be called (from toggleRememberMe) + await waitFor( + () => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }, + { timeout: 3000 }, + ); + + // Wait for updateAuthPreference to be called + await waitFor( + () => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + ); + }, + { timeout: 3000 }, + ); + + // Wait for removeItem to be called + await waitFor( + () => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }, + { timeout: 3000 }, + ); + }); + + it('successfully disables remember me and restores stored previous auth type', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + }); + + await waitFor(() => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + }); + + it('navigates to password entry when password is required for disabling remember me', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('restores auth preference when password is provided via callback when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + 'test-password', + ); + }); + + await waitFor(() => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + } + }); + + it('restores stored previous auth type when password is provided via callback when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem + .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC) + .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('reverts flag when password entry callback fails when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const updateError = new Error('Update failed'); + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(updateError); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + updateError, + 'Failed to restore auth preference after password entry', + ); + }); + } + }); + + it('calls Logger.error when updateAuthPreference fails when disabling', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const error = new Error('Restore failed'); + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + error, + 'Failed to restore auth preference when disabling remember me', + ); + }); + }); + + it('proceeds with toggle when getType returns non-REMEMBER_ME when trying to disable', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); + + it('proceeds with toggle when allowLoginWithRememberMe is false but user tries to disable', async () => { + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx index 8eb7bc42d8c5..75d0a59b9bdd 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle'; import { strings } from '../../../../../../locales/i18n'; import { useSelector, useDispatch } from 'react-redux'; @@ -9,42 +9,162 @@ import { createTurnOffRememberMeModalNavDetails } from '../../../..//UI/TurnOffR import { Authentication } from '../../../../../core'; import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants'; +import Logger from '../../../../../util/Logger'; +import AuthenticationError from '../../../../../core/Authentication/AuthenticationError'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import StorageWrapper from '../../../../../store/storage-wrapper'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage'; const RememberMeOptionSection = () => { const { navigate } = useNavigation(); const allowLoginWithRememberMe = useSelector( // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state: any) => state.security.allowLoginWithRememberMe, + (state: any) => state.security?.allowLoginWithRememberMe, ); - const [isUsingRememberMe, setIsUsingRememberMe] = useState(false); - useEffect(() => { - const checkIfAlreadyUsingRememberMe = async () => { - const authType = await Authentication.getType(); - setIsUsingRememberMe( - authType.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME, - ); - }; - checkIfAlreadyUsingRememberMe(); - }, []); - const dispatch = useDispatch(); const toggleRememberMe = useCallback( - (value: boolean) => { - dispatch(setAllowLoginWithRememberMe(value)); + async (value: boolean) => { + // If enabling remember me, update the password storage type first + if (value) { + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.REMEMBER_ME, + enteredPassword, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (updateError) { + // If update fails, revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } + }, + }); + return; + } + // Other error - revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + error as Error, + 'Failed to update auth preference for remember me', + ); + } + } else { + // Disabling remember me - restore previous authentication method + try { + // Get the previous auth type that was stored before enabling remember me + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Determine which auth method to restore + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + await Authentication.updateAuthPreference(authTypeToRestore); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + try { + await Authentication.updateAuthPreference( + authTypeToRestore, + enteredPassword, + ); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (updateError) { + // If update fails, revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(true)); + Logger.error( + updateError as Error, + 'Failed to restore auth preference after password entry', + ); + } + }, + }); + // Don't set Redux state here - wait for callback to complete + return; + } + // Other error - revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(true)); + Logger.error( + error as Error, + 'Failed to restore auth preference when disabling remember me', + ); + } + } }, - [dispatch], + [dispatch, navigate], ); const onValueChanged = useCallback( - (enabled: boolean) => { - isUsingRememberMe - ? navigate(...createTurnOffRememberMeModalNavDetails()) - : toggleRememberMe(enabled); + async (enabled: boolean) => { + // Check if remember me is currently active by checking the actual auth type + // This ensures we always have the current state + if (!enabled && allowLoginWithRememberMe) { + // User is trying to disable remember me - check if it's actually active + const authType = await Authentication.getType(); + if (authType.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME) { + navigate(...createTurnOffRememberMeModalNavDetails()); + return; + } + } + // Otherwise, proceed with normal toggle + await toggleRememberMe(enabled); }, - [isUsingRememberMe, navigate, toggleRememberMe], + [allowLoginWithRememberMe, navigate, toggleRememberMe], ); return ( diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx index 5834d8f0a31a..fff14451ca80 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx @@ -20,6 +20,8 @@ import { SecurityPrivacyViewSelectorsIDs } from '../../../../../e2e/selectors/Se import SECURITY_ALERTS_TOGGLE_TEST_ID from './constants'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; import { strings } from '../../../../../locales/i18n'; +import ReduxService from '../../../../core/redux/ReduxService'; +import { ReduxStore } from '../../../../core/redux/types'; const initialState = { privacy: { approvedHosts: {} }, @@ -85,14 +87,30 @@ describe('SecuritySettings', () => { mockUseParamsValues = { scrollToDetectNFTs: undefined, }; + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: jest.fn(), + getState: () => ({ + user: { existingUser: false }, + security: { allowLoginWithRememberMe: true }, + settings: { lockTime: 1000 }, + }), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); - it('should render correctly', () => { + it('renders correctly', () => { const wrapper = renderWithProvider(, { state: initialState, }); expect(wrapper.toJSON()).toMatchSnapshot(); }); - it('should render all sections', () => { + it('renders all sections', () => { const { getByText, getByTestId } = renderWithProvider( , { diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx index 6179152a25f9..dbdbc3734930 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx @@ -1,37 +1,18 @@ /* eslint-disable react/prop-types */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - Alert, - Switch, - ScrollView, - View, - ActivityIndicator, - Keyboard, - Linking, -} from 'react-native'; +import { Switch, ScrollView, View, Keyboard, Linking } from 'react-native'; import StorageWrapper from '../../../../store/storage-wrapper'; import { useDispatch, useSelector } from 'react-redux'; import { MAINNET } from '../../../../constants/network'; import ActionModal from '../../../UI/ActionModal'; import { clearHistory } from '../../../../actions/browser'; -import Logger from '../../../../util/Logger'; import { getNavigationOptionsTitle } from '../../../UI/Navbar'; -import { setLockTime } from '../../../../actions/settings'; import { SIMULATION_DETALS_ARTICLE_URL } from '../../../../constants/urls'; import { strings } from '../../../../../locales/i18n'; -import { passwordSet, setExistingUser } from '../../../../actions/user'; import Engine from '../../../../core/Engine'; -import AppConstants from '../../../../core/AppConstants'; -import { - TRUE, - PASSCODE_DISABLED, - BIOMETRY_CHOICE_DISABLED, - SEED_PHRASE_HINTS, -} from '../../../../constants/storage'; +import { SEED_PHRASE_HINTS } from '../../../../constants/storage'; import HintModal from '../../../UI/HintModal'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; -import { Authentication } from '../../../../core'; -import AUTHENTICATION_TYPE from '../../../../constants/userProperties'; import { useTheme } from '../../../../util/theme'; import { ClearCookiesSection, @@ -55,9 +36,7 @@ import { HeadingProps, SecuritySettingsParams } from './SecuritySettings.types'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../util/navigation/navUtils'; import { - BIOMETRY_CHOICE_STRING, CLEAR_BROWSER_HISTORY_SECTION, - PASSCODE_CHOICE_STRING, SDK_SECTION, } from './SecuritySettings.constants'; import Text, { @@ -69,7 +48,6 @@ import Button, { ButtonSize, ButtonWidthTypes, } from '../../../../component-library/components/Buttons/Button'; -import trackErrorAsAnalytics from '../../../../util/metrics/TrackError/trackErrorAsAnalytics'; import BasicFunctionalityComponent from '../../../UI/BasicFunctionality/BasicFunctionality'; import Routes from '../../../../constants/navigation/Routes'; import MetaMetricsAndDataCollectionSection from './Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection'; @@ -105,7 +83,6 @@ const Settings: React.FC = () => { const navigation = useNavigation(); const params = useParams(); const dispatch = useDispatch(); - const [loading, setLoading] = useState(false); const [browserHistoryModalVisible, setBrowserHistoryModalVisible] = useState(false); const [analyticsEnabled, setAnalyticsEnabled] = useState(false); @@ -127,7 +104,6 @@ const Settings: React.FC = () => { (state: RootState) => state.browser.history, ); - const lockTime = useSelector((state: RootState) => state.settings.lockTime); const useTransactionSimulations = useSelector( selectUseTransactionSimulations, ); @@ -253,99 +229,6 @@ const Settings: React.FC = () => { } }; - const storeCredentials = async ( - password: string, - enabled: boolean, - authChoice: string, - ) => { - try { - await Authentication.resetPassword(); - - await Engine.context.KeyringController.exportSeedPhrase(password); - - // Mark user as existing when they set up authentication - dispatch(setExistingUser(true)); - - if (!enabled) { - setLoading(false); - if (authChoice === PASSCODE_CHOICE_STRING) { - await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - } else if (authChoice === BIOMETRY_CHOICE_STRING) { - await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); - await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - } - - return; - } - - try { - let authType; - if (authChoice === BIOMETRY_CHOICE_STRING) { - authType = AUTHENTICATION_TYPE.BIOMETRIC; - } else if (authChoice === PASSCODE_CHOICE_STRING) { - authType = AUTHENTICATION_TYPE.PASSCODE; - } else { - authType = AUTHENTICATION_TYPE.PASSWORD; - } - await Authentication.storePassword(password, authType); - } catch (error) { - Logger.error(error as unknown as Error, {}); - } - - dispatch(passwordSet()); - - if (lockTime === -1) { - dispatch(setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT)); - } - setLoading(false); - } catch (e) { - const errorWithMessage = e as { message: string }; - if (errorWithMessage.message === 'Invalid password') { - Alert.alert( - strings('app_settings.invalid_password'), - strings('app_settings.invalid_password_message'), - ); - trackErrorAsAnalytics( - 'SecuritySettings: Invalid password', - errorWithMessage?.message, - '', - ); - } else { - Logger.error(e as unknown as Error, 'SecuritySettings:biometrics'); - } - setLoading(false); - } - }; - - const setPassword = async (enabled: boolean, passwordType: string) => { - setLoading(true); - let credentials; - try { - credentials = await Authentication.getPassword(); - } catch (error) { - Logger.error(error as unknown as Error, {}); - } - - if (credentials && credentials.password !== '') { - storeCredentials(credentials.password, enabled, passwordType); - } else { - setLoading(false); - navigation.navigate('EnterPasswordSimple', { - onPasswordSet: (password: string) => { - storeCredentials(password, enabled, passwordType); - }, - }); - } - }; - - const onSignInWithPasscode = async (enabled: boolean) => { - await setPassword(enabled, PASSCODE_CHOICE_STRING); - }; - - const onSingInWithBiometrics = async (enabled: boolean) => { - await setPassword(enabled, BIOMETRY_CHOICE_STRING); - }; - const goToSDKSessionManager = () => { navigation.navigate('SDKSessionsManager'); }; @@ -509,14 +392,6 @@ const Settings: React.FC = () => { }); }; - if (loading) { - return ( - - - - ); - } - const modalLoading = disableNotificationsLoading; const modalError = disableNotificationsError; @@ -535,10 +410,7 @@ const Settings: React.FC = () => { /> - + diff --git a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap index a88e6271bcca..ff233c699a0b 100644 --- a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap +++ b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SecuritySettings should render correctly 1`] = ` +exports[`SecuritySettings renders correctly 1`] = ` { false, // Exclude NavigationContainer since we're mocking navigation ); - expect(getByText('Trending Tokens')).toBeOnTheScreen(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen(); }); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 3b455246baa5..77075bdc5344 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -111,7 +111,6 @@ import ErrorBoundary from '../ErrorBoundary'; import { Token } from '@metamask/assets-controllers'; import { Hex, KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; -import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; import AccountGroupBalance from '../../UI/Assets/components/Balance/AccountGroupBalance'; @@ -1282,11 +1281,7 @@ const Wallet = ({ <> - {isMultichainAccountsState2Enabled ? ( - - ) : ( - - )} + ({ - useSelector: jest.fn(), -})); - -jest.mock('../../contexts/FeatureFlagOverrideContext', () => ({ - useFeatureFlagOverride: jest.fn(), -})); - -// Mock the useFeatureFlag module with mocked FeatureFlagNames enum -jest.mock('./useFeatureFlag', () => { - const actual = jest.requireActual('./useFeatureFlag'); - return { - ...actual, - FeatureFlagNames: { - mockedFlagEnabled: 'mockedFlagEnabled', - } as typeof actual.FeatureFlagNames, - }; -}); - -import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; - -// Type for the mocked FeatureFlagNames enum -type MockedFeatureFlagNames = typeof FeatureFlagNames & { - mockedFlagEnabled: 'mockedFlagEnabled'; -}; - -// Create a typed reference to the mocked flag -const MOCKED_FLAG = (FeatureFlagNames as MockedFeatureFlagNames) - .mockedFlagEnabled as FeatureFlagNames; - -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFeatureFlagOverride = - useFeatureFlagOverride as jest.MockedFunction; - -describe('useFeatureFlag', () => { - let mockGetFeatureFlag: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - - mockGetFeatureFlag = jest.fn(); - mockUseFeatureFlagOverride.mockReturnValue({ - getFeatureFlag: mockGetFeatureFlag, - } as unknown as ReturnType); - }); - - describe('when basic functionality is disabled', () => { - it('returns false without calling getFeatureFlag', () => { - mockUseSelector.mockReturnValue(false); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockUseSelector).toHaveBeenCalledWith( - selectBasicFunctionalityEnabled, - ); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - }); - - describe('when basic functionality is enabled', () => { - beforeEach(() => { - mockUseSelector.mockReturnValue(true); - }); - - it('returns true when getFeatureFlag returns true', () => { - mockGetFeatureFlag.mockReturnValue(true); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(true); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('returns false when getFeatureFlag returns false', () => { - mockGetFeatureFlag.mockReturnValue(false); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('returns undefined when getFeatureFlag returns undefined', () => { - mockGetFeatureFlag.mockReturnValue(undefined); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBeUndefined(); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('calls getFeatureFlag with the correct feature flag key', () => { - mockGetFeatureFlag.mockReturnValue(true); - - renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - }); - - it('calls useSelector with selectBasicFunctionalityEnabled selector', () => { - mockGetFeatureFlag.mockReturnValue(true); - - renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(mockUseSelector).toHaveBeenCalledWith( - selectBasicFunctionalityEnabled, - ); - expect(mockUseSelector).toHaveBeenCalledTimes(1); - }); - }); - - describe('edge cases', () => { - it('returns false when basic functionality is null', () => { - mockUseSelector.mockReturnValue(null as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is undefined', () => { - mockUseSelector.mockReturnValue(undefined as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is 0', () => { - mockUseSelector.mockReturnValue(0 as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is empty string', () => { - mockUseSelector.mockReturnValue('' as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/hooks/useFeatureFlag.ts b/app/components/hooks/useFeatureFlag.ts deleted file mode 100644 index 9d3be7bf2507..000000000000 --- a/app/components/hooks/useFeatureFlag.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useFeatureFlagOverride } from '../../contexts/FeatureFlagOverrideContext'; -import { selectBasicFunctionalityEnabled } from '../../selectors/settings'; - -export enum FeatureFlagNames { - rewardsEnabled = 'rewardsEnabled', - otaUpdatesEnabled = 'otaUpdatesEnabled', - rewardsEnableMusdHolding = 'rewardsEnableMusdHolding', - fullPageAccountList = 'fullPageAccountList', -} - -export const useFeatureFlag = (key: FeatureFlagNames) => { - const { getFeatureFlag } = useFeatureFlagOverride(); - const isBasicFunctionalityEnabled = useSelector( - selectBasicFunctionalityEnabled, - ); - if (!isBasicFunctionalityEnabled) { - return false; - } - return getFeatureFlag(key); -}; diff --git a/app/components/hooks/useOTAUpdates.test.ts b/app/components/hooks/useOTAUpdates.test.ts index 56e177e8a8d1..df54aebdacff 100644 --- a/app/components/hooks/useOTAUpdates.test.ts +++ b/app/components/hooks/useOTAUpdates.test.ts @@ -6,7 +6,6 @@ import { reloadAsync, UpdateCheckResultNotAvailableReason, } from 'expo-updates'; -import { useFeatureFlag } from './useFeatureFlag'; import { useOTAUpdates } from './useOTAUpdates'; import Logger from '../../util/Logger'; @@ -16,13 +15,15 @@ jest.mock('expo-updates', () => ({ reloadAsync: jest.fn(), })); -jest.mock('./useFeatureFlag', () => { - const actual = jest.requireActual('./useFeatureFlag'); - return { - ...actual, - useFeatureFlag: jest.fn(), - }; -}); +const mockSelectOtaUpdatesEnabledFlag = jest.fn(); +jest.mock('../../selectors/featureFlagController/otaUpdates', () => ({ + selectOtaUpdatesEnabledFlag: () => mockSelectOtaUpdatesEnabledFlag(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: () => unknown) => selector(), +})); jest.mock('../../util/Logger', () => ({ log: jest.fn(), @@ -42,9 +43,6 @@ const mockManifest = { }; describe('useOTAUpdates', () => { - const mockUseFeatureFlag = useFeatureFlag as jest.MockedFunction< - typeof useFeatureFlag - >; const mockCheckForUpdateAsync = checkForUpdateAsync as jest.MockedFunction< typeof checkForUpdateAsync >; @@ -60,12 +58,12 @@ describe('useOTAUpdates', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); (global as unknown as { __DEV__: boolean }).__DEV__ = false; }); it('returns isCheckingUpdates as false when feature flag is disabled', async () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); const { result } = renderHook(() => useOTAUpdates()); @@ -77,7 +75,7 @@ describe('useOTAUpdates', () => { it('skips update check in development mode', async () => { (global as unknown as { __DEV__: boolean }).__DEV__ = true; - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); const { result } = renderHook(() => useOTAUpdates()); @@ -88,7 +86,7 @@ describe('useOTAUpdates', () => { }); it('checks for updates when feature flag is enabled', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -104,7 +102,7 @@ describe('useOTAUpdates', () => { }); it('sets isCheckingUpdates to false when no update is available', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -121,7 +119,7 @@ describe('useOTAUpdates', () => { }); it('fetches and reloads when a new update is available', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -145,7 +143,7 @@ describe('useOTAUpdates', () => { }); it('sets isCheckingUpdates to false when update is fetched but not new', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -168,7 +166,7 @@ describe('useOTAUpdates', () => { it('logs error and sets isCheckingUpdates to false when check fails', async () => { const mockError = new Error('Update check failed'); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockRejectedValue(mockError); const { result } = renderHook(() => useOTAUpdates()); @@ -184,7 +182,7 @@ describe('useOTAUpdates', () => { it('does not block app if reload fails', async () => { const mockError = new Error('Reload failed'); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -210,7 +208,7 @@ describe('useOTAUpdates', () => { }); it('checks for updates when feature flag changes from disabled to enabled', async () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -225,7 +223,7 @@ describe('useOTAUpdates', () => { expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); }); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); rerender(); await waitFor(() => { @@ -234,7 +232,9 @@ describe('useOTAUpdates', () => { }); it('does not check for updates again when feature flag changes from enabled to disabled', async () => { - mockUseFeatureFlag.mockReturnValueOnce(true).mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag + .mockReturnValueOnce(true) + .mockReturnValue(false); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -256,7 +256,7 @@ describe('useOTAUpdates', () => { }); it('starts with isCheckingUpdates as true', () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); const { result } = renderHook(() => useOTAUpdates()); @@ -264,7 +264,7 @@ describe('useOTAUpdates', () => { }); it('calls update check, fetch, and reload in order', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, diff --git a/app/components/hooks/useOTAUpdates.ts b/app/components/hooks/useOTAUpdates.ts index a2b34db68738..42117f715ddf 100644 --- a/app/components/hooks/useOTAUpdates.ts +++ b/app/components/hooks/useOTAUpdates.ts @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { checkForUpdateAsync, fetchUpdateAsync, reloadAsync, } from 'expo-updates'; import Logger from '../../util/Logger'; -import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; +import { selectOtaUpdatesEnabledFlag } from '../../selectors/featureFlagController/otaUpdates'; /** * Hook to manage OTA updates based on feature flag @@ -14,7 +15,7 @@ import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; * Returns isCheckingUpdates to gate rendering until check is complete */ export const useOTAUpdates = () => { - const otaUpdatesEnabled = useFeatureFlag(FeatureFlagNames.otaUpdatesEnabled); + const otaUpdatesEnabled = useSelector(selectOtaUpdatesEnabledFlag); const [isCheckingUpdates, setIsCheckingUpdates] = useState(true); useEffect(() => { diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts new file mode 100644 index 000000000000..f184f351d1b4 --- /dev/null +++ b/app/constants/featureFlags.ts @@ -0,0 +1,11 @@ +/** + * Feature flag names that can be overridden in development tools. + * These correspond to remote feature flags that have selector implementations + * in app/selectors/featureFlagController/ + */ +export enum FeatureFlagNames { + rewardsEnabled = 'rewardsEnabled', + otaUpdatesEnabled = 'otaUpdatesEnabled', + rewardsEnableMusdHolding = 'rewardsEnableMusdHolding', + fullPageAccountList = 'fullPageAccountList', +} diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index dc0361e7bb7b..9e1673a4ccfb 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -186,7 +186,6 @@ const Routes = { ORIGIN_SPAM_MODAL: 'OriginSpamModal', TOOLTIP_MODAL: 'tooltipModal', TOKEN_SORT: 'TokenSort', - TOKEN_FILTER: 'TokenFilter', NETWORK_MANAGER: 'NetworkManager', CHANGE_IN_SIMULATION_MODAL: 'ChangeInSimulationModal', SELECT_SRP: 'SelectSRP', diff --git a/app/constants/storage.ts b/app/constants/storage.ts index 57d399ddd616..13e209b7961a 100644 --- a/app/constants/storage.ts +++ b/app/constants/storage.ts @@ -10,6 +10,8 @@ export const BIOMETRY_CHOICE_DISABLED = `${prefix}biometryChoiceDisabled`; export const PASSCODE_CHOICE = `${prefix}passcodeChoice`; export const PASSCODE_DISABLED = `${prefix}passcodeDisabled`; +export const PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME = `${prefix}previousAuthTypeBeforeRememberMe`; + export const METRICS_OPT_IN = `${prefix}metricsOptIn`; export const METRICS_OPT_IN_SOCIAL_LOGIN = `${prefix}metricsOptInSocialLogin`; export const ANALYTICS_DATA_DELETION_TASK_ID = `${prefix}analyticsDataDeletionTaskId`; diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index 99c1c0e683dd..ad9e2277f3e7 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -5,6 +5,7 @@ import { PASSCODE_DISABLED, SOLANA_DISCOVERY_PENDING, OPTIN_META_METRICS_UI_SEEN, + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, BIOMETRY_CHOICE, } from '../../constants/storage'; import { Authentication } from './Authentication'; @@ -37,7 +38,12 @@ import { import { EncryptionKey } from '@metamask/browser-passworder'; import { uint8ArrayToMnemonic } from '../../util/mnemonic'; import { SolScope } from '@metamask/keyring-api'; -import { logOut, setExistingUser, logIn } from '../../actions/user'; +import { + logOut, + setExistingUser, + logIn, + passwordSet, +} from '../../actions/user'; import { setCompletedOnboarding } from '../../actions/onboarding'; import { RootState } from '../../reducers'; import { @@ -50,8 +56,12 @@ import { resetProviderToken as depositResetProviderToken } from '../../component import { clearAllVaultBackups } from '../BackupVault/backupVault'; import { Engine as EngineClass } from '../Engine/Engine'; import Logger from '../../util/Logger'; -import Routes from '../../constants/navigation/Routes'; +import { Alert } from 'react-native'; import { strings } from '../../../locales/i18n'; +import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import AppConstants from '../AppConstants'; +import { setLockTime } from '../../actions/settings'; +import Routes from '../../constants/navigation/Routes'; import { IconName } from '../../component-library/components/Icons/Icon'; import { ReauthenticateErrorType } from './types'; @@ -243,6 +253,20 @@ jest.mock('../../util/Logger', () => ({ log: jest.fn(), })); +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})); + +jest.mock('../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../util/metrics/TrackError/trackErrorAsAnalytics', () => + jest.fn(), +); + const mockTrace = jest.fn(); const mockEndTrace = jest.fn(); const mockGetTraceTags = jest.fn(); @@ -266,14 +290,13 @@ describe('Authentication', () => { jest.runAllTimers(); }); - it('should return a type password', async () => { + it('returns PASSWORD type when biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - // Mock Redux store to return existingUser: false jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ getState: () => ({ user: { existingUser: false }, @@ -282,11 +305,12 @@ describe('Authentication', () => { } as unknown as ReduxStore); const result = await Authentication.getType(); + expect(result.availableBiometryType).toEqual('FaceID'); expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type biometric', async () => { + it('returns BIOMETRIC type when biometric is available and not disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); @@ -304,7 +328,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); }); - it('should return a type passcode', async () => { + it('returns PASSCODE type when biometric is disabled but passcode is available', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -323,7 +347,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSCODE); }); - it('should return a type password with biometric & pincode disabled', async () => { + it('returns PASSWORD type when both biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -343,7 +367,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type AUTHENTICATION_TYPE.REMEMBER_ME if the user exists and there are no available biometrics options and the password exist in the keychain', async () => { + it('returns REMEMBER_ME type when user exists, no biometrics available, and password exists in keychain', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); const mockCredentials = { username: 'test', password: 'test' }; SecureKeychain.getGenericPassword = jest @@ -363,7 +387,92 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); }); - it('should return a type AUTHENTICATION_TYPE.PASSWORD if the user exists and there are no available biometrics options but the password does not exist in the keychain', async () => { + it('prioritizes REMEMBER_ME over BIOMETRIC when remember me is enabled', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true and remember me enabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); + expect(result.availableBiometryType).toEqual('FaceID'); + }); + + it('prioritizes REMEMBER_ME over PASSCODE when remember me is enabled', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true and remember me enabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); + expect(result.availableBiometryType).toEqual('Fingerprint'); + }); + + it('returns BIOMETRIC when remember me is disabled even if password exists', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true but remember me disabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: false }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); + expect(result.availableBiometryType).toEqual('FaceID'); + }); + + it('returns BIOMETRIC when remember me is enabled but password does not exist in keychain', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); + }); + + it('returns PASSWORD type when user exists, no biometrics available, and password does not exist in keychain', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); @@ -380,7 +489,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type AUTHENTICATION_TYPE.PASSWORD if the user does not exist and there are no available biometrics options', async () => { + it('returns PASSWORD type when user does not exist and no biometrics are available', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); // Mock Redux store to return existingUser: false @@ -396,7 +505,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a auth type for components AUTHENTICATION_TYPE.REMEMBER_ME', async () => { + it('returns REMEMBER_ME type for components when remember me is enabled', async () => { jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); @@ -413,7 +522,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); }); - it('should return a auth type for components AUTHENTICATION_TYPE.PASSWORD', async () => { + it('returns PASSWORD type for components when both biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -433,7 +542,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a auth type for components AUTHENTICATION_TYPE.PASSCODE', async () => { + it('returns PASSCODE type for components when biometric is disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -452,7 +561,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSCODE); }); - it('should return a auth type for components AUTHENTICATION_TYPE.BIOMETRIC', async () => { + it('returns BIOMETRIC type for components when biometric is available', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -470,86 +579,152 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); }); - describe('storePassword', () => { - it('should store password with BIOMETRIC authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + describe('storePassword (protected method tested via updateAuthPreference)', () => { + const mockPassword = 'test-password-123'; + let Engine: typeof import('../Engine').default; + let mockDispatch: jest.Mock; - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.BIOMETRIC); + beforeEach(() => { + Engine = jest.requireMock('../Engine'); + mockDispatch = jest.fn(); + jest.clearAllMocks(); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + user: { existingUser: true }, + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + Engine.context.KeyringController.exportSeedPhrase = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.exportSeedPhrase + >; + + jest.spyOn(Authentication, 'getPassword').mockResolvedValue({ + password: mockPassword, + username: 'metamask-user', + } as unknown as import('react-native-keychain').UserCredentials); + + jest.spyOn(Authentication, 'resetPassword').mockResolvedValue(undefined); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockResolvedValue(undefined); + + // Mock SecureKeychain methods needed by checkAuthenticationMethod + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + StorageWrapper.clearAll(); + }); + + it('stores password with BIOMETRIC and manages storage flags correctly', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.BIOMETRICS, ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); }); - it('should store password with PASSCODE authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with PASSCODE and manages storage flags correctly', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.PASSCODE); + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSCODE, + mockPassword, + ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.PASSCODE, ); + expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); }); - it('should store password with REMEMBER_ME authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with REMEMBER_ME and does not affect biometric/passcode flags', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword( - '1234', + await Authentication.updateAuthPreference( AUTHENTICATION_TYPE.REMEMBER_ME, + mockPassword, ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.REMEMBER_ME, ); - }); - - it('should store password with PASSWORD authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', + // Should not remove or set biometric/passcode flags directly + expect(removeItemSpy).not.toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(removeItemSpy).not.toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).not.toHaveBeenCalledWith( + BIOMETRY_CHOICE_DISABLED, + expect.anything(), + ); + expect(setItemSpy).not.toHaveBeenCalledWith( + PASSCODE_DISABLED, + expect.anything(), + ); + // But can store previous auth type (expected behavior) + expect(setItemSpy).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + expect.any(String), ); - - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.PASSWORD); - - expect(setGenericPasswordSpy).toHaveBeenCalledWith('1234', undefined); }); - it('should store password with UNKNOWN authentication type (default case)', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with PASSWORD and disables both biometric and passcode', async () => { + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.UNKNOWN); + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSWORD, + mockPassword, + ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith('1234', undefined); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + undefined, + ); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); }); - it('should throw AuthenticationError when SecureKeychain fails', async () => { + it('throws AuthenticationError when SecureKeychain fails', async () => { const error = new Error('Keychain error'); jest .spyOn(SecureKeychain, 'setGenericPassword') .mockRejectedValueOnce(error); + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSWORD, + mockPassword, + ), + ).rejects.toThrow(AuthenticationError); + try { - await Authentication.storePassword( - '1234', + await Authentication.updateAuthPreference( AUTHENTICATION_TYPE.PASSWORD, + mockPassword, ); - throw new Error('Expected an error to be thrown'); } catch (authError) { expect(authError).toBeInstanceOf(AuthenticationError); expect((authError as AuthenticationError).customErrorMessage).toBe( @@ -562,23 +737,27 @@ describe('Authentication', () => { }); it('falls back to PASSWORD authType when biometric storePassword fails in newWalletAndKeychain', async () => { - const mockDispatch = jest.fn(); + const fallbackMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: fallbackMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const fallbackEngine = jest.requireMock('../Engine'); // Mock successful vault creation - Engine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( + fallbackEngine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + fallbackEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail on first call (biometric), succeed on second (password) + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValueOnce(new Error('Biometric storage failed')) .mockResolvedValueOnce(undefined); @@ -586,7 +765,7 @@ describe('Authentication', () => { currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, }); - // Should have called storePassword twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) + // Verifies storePassword was called twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) expect(storePasswordSpy).toHaveBeenCalledTimes(2); expect(storePasswordSpy).toHaveBeenNthCalledWith( 1, @@ -599,31 +778,35 @@ describe('Authentication', () => { AUTHENTICATION_TYPE.PASSWORD, ); - // Should have completed successfully - expect(mockDispatch).toHaveBeenCalledWith(setExistingUser(true)); - expect(mockDispatch).toHaveBeenCalledWith(logIn()); + // Verifies operation completed successfully + expect(fallbackMockDispatch).toHaveBeenCalledWith(setExistingUser(true)); + expect(fallbackMockDispatch).toHaveBeenCalledWith(logIn()); storePasswordSpy.mockRestore(); }); it('falls back to PASSWORD authType when biometric storePassword fails in newWalletAndRestore', async () => { - const mockDispatch = jest.fn(); + const restoreMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: restoreMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const restoreEngine = jest.requireMock('../Engine'); // Mock successful vault restoration - Engine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( + restoreEngine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + restoreEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail on first call (biometric), succeed on second (password) + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValueOnce(new Error('Biometric storage failed')) .mockResolvedValueOnce(undefined); @@ -636,7 +819,7 @@ describe('Authentication', () => { true, ); - // Should have called storePassword twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) + // Verifies storePassword was called twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) expect(storePasswordSpy).toHaveBeenCalledTimes(2); expect(storePasswordSpy).toHaveBeenNthCalledWith( 1, @@ -649,33 +832,39 @@ describe('Authentication', () => { AUTHENTICATION_TYPE.PASSWORD, ); - // Should have completed successfully - expect(mockDispatch).toHaveBeenCalledWith(setExistingUser(true)); - expect(mockDispatch).toHaveBeenCalledWith(logIn()); + // Verifies operation completed successfully + expect(restoreMockDispatch).toHaveBeenCalledWith(setExistingUser(true)); + expect(restoreMockDispatch).toHaveBeenCalledWith(logIn()); storePasswordSpy.mockRestore(); }); it('throws error when PASSWORD authType storePassword fails in newWalletAndKeychain', async () => { - const mockDispatch = jest.fn(); + const errorMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: errorMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const errorEngine = jest.requireMock('../Engine'); - Engine.context.KeyringController.setLocked.mockResolvedValue(undefined); + errorEngine.context.KeyringController.setLocked.mockResolvedValue( + undefined, + ); // Mock successful vault creation - Engine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( + errorEngine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + errorEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail even with PASSWORD authType + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValue(new Error('Password storage failed')); try { @@ -691,36 +880,44 @@ describe('Authentication', () => { expect((error as AuthenticationError).message).toBe( 'Password storage failed', ); - // Should have called storePassword only once since it's PASSWORD authType (no fallback) + // Verifies storePassword was called only once since it's PASSWORD authType (no fallback) expect(storePasswordSpy).toHaveBeenCalledTimes(1); await Promise.resolve(); jest.runAllTimers(); - expect(mockDispatch).toHaveBeenCalledWith(logOut()); + expect(errorMockDispatch).toHaveBeenCalledWith(logOut()); } storePasswordSpy.mockRestore(); }); it('throws error when PASSWORD authType storePassword fails in newWalletAndRestore', async () => { - const mockDispatch = jest.fn(); + const restoreErrorMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: restoreErrorMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const restoreErrorEngine = jest.requireMock('../Engine'); - Engine.context.KeyringController.setLocked.mockResolvedValue(undefined); + restoreErrorEngine.context.KeyringController.setLocked.mockResolvedValue( + undefined, + ); // Mock successful vault restoration - Engine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( + restoreErrorEngine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + restoreErrorEngine.resetState = jest + .fn() + .mockResolvedValueOnce(undefined); // Mock storePassword to fail even with PASSWORD authType + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValue(new Error('Password storage failed')); try { @@ -741,11 +938,11 @@ describe('Authentication', () => { expect((error as AuthenticationError).message).toBe( 'Password storage failed', ); - // Should have called storePassword only once since it's PASSWORD authType (no fallback) + // Verifies storePassword was called only once since it's PASSWORD authType (no fallback) expect(storePasswordSpy).toHaveBeenCalledTimes(1); await Promise.resolve(); jest.runAllTimers(); - expect(mockDispatch).toHaveBeenCalledWith(logOut()); + expect(restoreErrorMockDispatch).toHaveBeenCalledWith(logOut()); } storePasswordSpy.mockRestore(); @@ -981,7 +1178,7 @@ describe('Authentication', () => { expect.any(Error), ); - // Should not attempt discovery due to storage error + // Does not attempt discovery due to storage error expect(mockAttemptAccountDiscovery).not.toHaveBeenCalled(); // Restore original method @@ -999,7 +1196,7 @@ describe('Authentication', () => { .fn() .mockReturnValue(mockCredentials); - // Should not throw and should complete authentication + // Does not throw and completes authentication await expect( Authentication.appTriggeredAuth(), ).resolves.not.toThrow(); @@ -1173,7 +1370,7 @@ describe('Authentication', () => { Engine.context.KeyringController.state.keyrings = [ { type: KeyringTypes.hd, metadata: { id: 'test-keyring-1' } }, { type: KeyringTypes.hd, metadata: { id: 'test-keyring-2' } }, - // Should not run discovery for this one. + // Does not run discovery for this one. { type: KeyringTypes.simple, metadata: { id: 'test-keyring-3' } }, ]; @@ -1321,7 +1518,7 @@ describe('Authentication', () => { }); describe('resetPassword', () => { - it('should call SecureKeychain.resetGenericPassword', async () => { + it('calls SecureKeychain.resetGenericPassword', async () => { const resetGenericPasswordSpy = jest.spyOn( SecureKeychain, 'resetGenericPassword', @@ -1332,7 +1529,7 @@ describe('Authentication', () => { expect(resetGenericPasswordSpy).toHaveBeenCalled(); }); - it('should throw AuthenticationError when SecureKeychain fails', async () => { + it('throws AuthenticationError when SecureKeychain fails', async () => { const error = new Error('Reset failed'); jest .spyOn(SecureKeychain, 'resetGenericPassword') @@ -1733,7 +1930,7 @@ describe('Authentication', () => { expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); - it('should throw an error if first seed phrase is falsy', async () => { + it('throws error when first seed phrase is falsy', async () => { ( Engine.context.SeedlessOnboardingController .fetchAllSecretData as jest.Mock @@ -3558,6 +3755,302 @@ describe('Authentication', () => { }); }); + describe('updateAuthPreference', () => { + const mockPassword = 'test-password-123'; + + let Engine: typeof import('../Engine').default; + let mockDispatch: jest.Mock; + + beforeEach(() => { + Engine = jest.requireMock('../Engine'); + mockDispatch = jest.fn(); + jest.clearAllMocks(); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + Engine.context.KeyringController.exportSeedPhrase = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.exportSeedPhrase + >; + + Engine.context.KeyringController.verifyPassword = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.verifyPassword + >; + + jest.spyOn(Authentication, 'getPassword').mockResolvedValue({ + password: mockPassword, + username: 'metamask-user', + } as unknown as import('react-native-keychain').UserCredentials); + + jest.spyOn(Authentication, 'resetPassword').mockResolvedValue(undefined); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + StorageWrapper.clearAll(); + }); + + it('updates auth preference to BIOMETRIC with password from keychain', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.BIOMETRIC); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to BIOMETRIC with provided password', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(Authentication.getPassword).not.toHaveBeenCalled(); + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to PASSCODE with password from keychain', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.PASSCODE); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.PASSCODE, + ); + expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to PASSWORD with password from keychain', async () => { + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.PASSWORD); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + undefined, + ); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates lock time when lockTime is -1', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: -1 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + expect(mockDispatch).toHaveBeenCalledWith( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + }); + + it('does not update lock time when lockTime is not -1', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + expect(mockDispatch).not.toHaveBeenCalledWith( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + }); + + it('shows alert and tracks error when password is invalid', async () => { + const invalidPasswordError = new Error('Invalid password'); + ( + Engine.context.KeyringController.verifyPassword as jest.Mock + ).mockRejectedValueOnce(invalidPasswordError); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ), + ).rejects.toThrow('Invalid password'); + + expect(alertSpy).toHaveBeenCalledWith( + strings('app_settings.invalid_password'), + strings('app_settings.invalid_password_message'), + ); + expect(trackErrorSpy).toHaveBeenCalledWith( + 'SecuritySettings: Invalid password', + 'Invalid password', + '', + ); + + alertSpy.mockRestore(); + }); + + it('logs error for non-invalid-password errors', async () => { + const otherError = new Error('Store password failed'); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockRejectedValueOnce(otherError); + const loggerErrorSpy = jest.spyOn(Logger, 'error'); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ), + ).rejects.toThrow('Store password failed'); + + expect(alertSpy).not.toHaveBeenCalled(); + expect(trackErrorSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.any(Error), + 'SecuritySettings:biometrics', + ); + + alertSpy.mockRestore(); + }); + + it('converts BIOMETRIC_NOT_ENABLED error to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS', async () => { + // Mock reauthenticate to throw BIOMETRIC_NOT_ENABLED error + const biometricNotEnabledError = new Error( + `${ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED}: Biometric is not enabled`, + ); + jest + .spyOn(Authentication, 'reauthenticate') + .mockRejectedValueOnce(biometricNotEnabledError); + + const loggerErrorSpy = jest.spyOn(Logger, 'error'); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + // Verify the error is converted to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS + let caughtError: unknown; + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + } catch (error) { + caughtError = error; + } + + // Verify it throws AuthenticationError + expect(caughtError).toBeInstanceOf(AuthenticationError); + + // Verify the error has the correct customErrorMessage + expect((caughtError as AuthenticationError).customErrorMessage).toBe( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ); + + // Verify that invalid password handling was not triggered + expect(alertSpy).not.toHaveBeenCalled(); + expect(trackErrorSpy).not.toHaveBeenCalled(); + + // Verify that Logger.error was not called (since this is a converted error) + expect(loggerErrorSpy).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('skips password validation when skipValidation is true', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + const verifyPasswordSpy = jest.spyOn( + Engine.context.KeyringController, + 'verifyPassword', + ); + + // Note: The actual implementation doesn't have skipValidation parameter + // This test should verify normal behavior + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect(verifyPasswordSpy).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + }); describe('checkAndShowSeedlessPasswordOutdatedModal', () => { let Engine: typeof import('../Engine').default; let mockIsOutdated: boolean = false; diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index fab5a8d8fb42..5433feb2d8a5 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -7,6 +7,7 @@ import { PASSCODE_DISABLED, SEED_PHRASE_HINTS, OPTIN_META_METRICS_UI_SEEN, + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, BIOMETRY_CHOICE, } from '../../constants/storage'; import { @@ -78,7 +79,11 @@ import { EntropySourceId } from '@metamask/keyring-api'; import { trackVaultCorruption } from '../../util/analytics/vaultCorruptionTracking'; import MetaMetrics from '../Analytics/MetaMetrics'; import { resetProviderToken as depositResetProviderToken } from '../../components/UI/Ramp/Deposit/utils/ProviderTokenVault'; +import { Alert } from 'react-native'; import { strings } from '../../../locales/i18n'; +import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import AppConstants from '../AppConstants'; +import { setLockTime } from '../../actions/settings'; import { IconName } from '../../component-library/components/Icons/Icon'; import { ReauthenticateErrorType } from './types'; @@ -350,6 +355,20 @@ class AuthenticationService { const passcodePreviouslyDisabled = await StorageWrapper.getItem(PASSCODE_DISABLED); + // Remember me should take priority over biometric/passcode + const existingUser = selectExistingUser(ReduxService.store.getState()); + const allowLoginWithRememberMe = + ReduxService.store.getState().security?.allowLoginWithRememberMe; + if (existingUser && allowLoginWithRememberMe) { + const credentials = await SecureKeychain.getGenericPassword(); + if (credentials && credentials.password) { + return { + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + availableBiometryType, + }; + } + } + if ( availableBiometryType && !(biometryPreviouslyDisabled && biometryPreviouslyDisabled === TRUE) @@ -358,7 +377,9 @@ class AuthenticationService { currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, availableBiometryType, }; - } else if ( + } + // Then check passcode + if ( availableBiometryType && !(passcodePreviouslyDisabled && passcodePreviouslyDisabled === TRUE) ) { @@ -367,15 +388,7 @@ class AuthenticationService { availableBiometryType, }; } - const existingUser = selectExistingUser(ReduxService.store.getState()); - if (existingUser) { - if (await SecureKeychain.getGenericPassword()) { - return { - currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, - availableBiometryType, - }; - } - } + // Default to password return { currentAuthType: AUTHENTICATION_TYPE.PASSWORD, availableBiometryType, @@ -396,39 +409,81 @@ class AuthenticationService { }; /** - * Stores a user password in the secure keychain with a specific auth type + * Stores a user password in the secure keychain with a specific auth type. + * This is the single source of truth for password persistence and manages + * all related storage flags to ensure authentication types are mutually exclusive. + * * @param password - password provided by user * @param authType - type of authentication required to fetch password from keychain + * @protected */ - storePassword = async ( + protected storePassword = async ( password: string, authType: AUTHENTICATION_TYPE, ): Promise => { try { + // Store password in keychain with appropriate type switch (authType) { case AUTHENTICATION_TYPE.BIOMETRIC: await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.BIOMETRICS, ); + await StorageWrapper.removeItem(BIOMETRY_CHOICE_DISABLED); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); + break; case AUTHENTICATION_TYPE.PASSCODE: await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.PASSCODE, ); + await StorageWrapper.removeItem(PASSCODE_DISABLED); + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); break; - case AUTHENTICATION_TYPE.REMEMBER_ME: + case AUTHENTICATION_TYPE.REMEMBER_ME: { + // Store the current auth type before switching to remember me + const currentAuthData = await this.checkAuthenticationMethod(); + // Only store if we're not already on remember me + if ( + currentAuthData.currentAuthType !== AUTHENTICATION_TYPE.REMEMBER_ME + ) { + await StorageWrapper.setItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + currentAuthData.currentAuthType, + ); + } + await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.REMEMBER_ME, ); + // SecureKeychain.setGenericPassword handles flag management for REMEMBER_ME + // (sets BIOMETRY_CHOICE_DISABLED and PASSCODE_DISABLED to disable biometric/passcode) break; - case AUTHENTICATION_TYPE.PASSWORD: + } + case AUTHENTICATION_TYPE.PASSWORD: { await SecureKeychain.setGenericPassword(password, undefined); + // Password only: disable both biometrics and passcode + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); + + // If remember me is enabled, clear the stored previous auth type + // because the user is disabling biometrics/passcode, so we shouldn't restore to them + const allowLoginWithRememberMe = + ReduxService.store.getState().security?.allowLoginWithRememberMe; + if (allowLoginWithRememberMe) { + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + } break; + } default: await SecureKeychain.setGenericPassword(password, undefined); + // Default to password behavior: disable both + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); break; } } catch (error) { @@ -1422,6 +1477,81 @@ class AuthenticationService { } } + /** + * Updates the authentication preference for the user. + * If password is provided, uses it directly. Otherwise, gets password from keychain. + * Validates the password and stores it with the new auth type. + * Manages storage flags (BIOMETRY_CHOICE_DISABLED, PASSCODE_DISABLED) based on auth type. + * Throws AuthenticationError if password is not found in keychain and not provided. + * Callers should handle navigation to password entry screen when this error is thrown. + * + * @param authType - type of authentication to use (BIOMETRIC, PASSCODE, or PASSWORD) + * @param password - optional password to use. If not provided, gets from keychain. + * @returns {Promise} + * @throws {AuthenticationError} when password is not found and not provided + */ + updateAuthPreference = async ( + authType: AUTHENTICATION_TYPE = AUTHENTICATION_TYPE.PASSWORD, + password?: string, + ): Promise => { + // Password found or provided. Validate and update the auth preference. + try { + const passwordToUse = await this.reauthenticate(password); + if (!passwordToUse.password) { + throw new AuthenticationError( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + this.authData, + ); + } + // TUDO: Check if this is really needed for IOS + await this.resetPassword(); + + // storePassword handles all storage flag management internally + await this.storePassword(passwordToUse.password, authType); + + ReduxService.store.dispatch(passwordSet()); + + const lockTime = ReduxService.store.getState().settings.lockTime; + if (lockTime === -1) { + ReduxService.store.dispatch( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + } + } catch (e) { + const errorWithMessage = e as { message: string }; + + // Check if the error is because biometrics are not enabled + // Convert it to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS so UI can handle it + if ( + errorWithMessage.message.includes( + ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED, + ) + ) { + throw new AuthenticationError( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + this.authData, + ); + } + + if (errorWithMessage.message === 'Invalid password') { + Alert.alert( + strings('app_settings.invalid_password'), + strings('app_settings.invalid_password_message'), + ); + trackErrorAsAnalytics( + 'SecuritySettings: Invalid password', + errorWithMessage?.message, + '', + ); + } else { + Logger.error(e as unknown as Error, 'SecuritySettings:biometrics'); + } + throw e; + } + }; + /** * If a password is provided, it is verified directly. Otherwise, this method * attempts to read the biometric preference from storage and, when enabled, diff --git a/app/core/SDKConnectV2/services/connection.test.ts b/app/core/SDKConnectV2/services/connection.test.ts index d2ad82915d27..051e75f8ff1b 100644 --- a/app/core/SDKConnectV2/services/connection.test.ts +++ b/app/core/SDKConnectV2/services/connection.test.ts @@ -13,7 +13,9 @@ import { KVStore } from '../store/kv-store'; import { RPCBridgeAdapter } from '../adapters/rpc-bridge-adapter'; import { ConnectionInfo } from '../types/connection-info'; import { HostApplicationAdapter } from '../adapters/host-application-adapter'; -import { errorCodes } from '@metamask/rpc-errors'; +import { errorCodes, providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../Engine'; +import NavigationService from '../../NavigationService'; jest.mock('@metamask/mobile-wallet-protocol-wallet-client'); jest.mock('@metamask/mobile-wallet-protocol-core', () => ({ @@ -25,6 +27,19 @@ jest.mock('@metamask/mobile-wallet-protocol-core', () => ({ })); jest.mock('../store/kv-store'); jest.mock('../adapters/rpc-bridge-adapter'); +jest.mock('../../Engine', () => ({ + context: { + ApprovalController: { + getTotalApprovalCount: jest.fn(), + clear: jest.fn().mockResolvedValue(undefined), + }, + }, +})); +jest.mock('../../NavigationService', () => ({ + navigation: { + goBack: jest.fn(), + }, +})); const MockedWalletClient = WalletClient as jest.MockedClass< typeof WalletClient @@ -195,7 +210,10 @@ describe('Connection', () => { mockHostApp, ); - const dAppPayload = { id: 1, method: 'eth_accounts', params: [] }; + const dAppPayload = { + name: 'metamask-provider', + data: { id: 1, method: 'eth_accounts', params: [] }, + }; // Simulate the WalletClient receiving a message onClientMessageCallback(dAppPayload); @@ -211,7 +229,10 @@ describe('Connection', () => { mockHostApp, ); - const walletPayload = { id: 1, result: ['0x123'] }; + const walletPayload = { + name: 'metamask-provider', + data: { id: 1, result: ['0x123'] }, + }; // Simulate the RPCBridgeAdapter emitting a response onBridgeResponseCallback(walletPayload); @@ -220,6 +241,77 @@ describe('Connection', () => { walletPayload, ); }); + + describe('wallet_createSession request', () => { + it('clears all pending approvals and navigates away from the open approval modal when there are pending approval requests', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + ( + Engine.context.ApprovalController.getTotalApprovalCount as jest.Mock + ).mockReturnValue(2); + + const walletCreateSessionPayload = { + name: 'metamask-multichain-provider', + data: { + method: 'wallet_createSession', + params: {}, + id: 1, + }, + }; + + await onClientMessageCallback(walletCreateSessionPayload); + + expect(NavigationService.navigation?.goBack).toHaveBeenCalledTimes(1); + expect(Engine.context.ApprovalController.clear).toHaveBeenCalledTimes( + 1, + ); + expect(Engine.context.ApprovalController.clear).toHaveBeenCalledWith( + providerErrors.userRejectedRequest({ + data: { + cause: 'rejectAllApprovals', + }, + }), + ); + expect(mockBridgeInstance.send).toHaveBeenCalledWith( + walletCreateSessionPayload, + ); + }); + + it('does not clear pending approvals or navigate away when there are no pending approval requests', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + ( + Engine.context.ApprovalController.getTotalApprovalCount as jest.Mock + ).mockReturnValue(0); + + const walletCreateSessionPayload = { + name: 'metamask-multichain-provider', + data: { + method: 'wallet_createSession', + params: {}, + id: 1, + }, + }; + + await onClientMessageCallback(walletCreateSessionPayload); + + expect(NavigationService.navigation?.goBack).not.toHaveBeenCalled(); + expect(Engine.context.ApprovalController.clear).not.toHaveBeenCalled(); + expect(mockBridgeInstance.send).toHaveBeenCalledWith( + walletCreateSessionPayload, + ); + }); + }); }); describe('resume', () => { diff --git a/app/core/SDKConnectV2/services/connection.ts b/app/core/SDKConnectV2/services/connection.ts index ea4b63845718..a19afc3ae76b 100644 --- a/app/core/SDKConnectV2/services/connection.ts +++ b/app/core/SDKConnectV2/services/connection.ts @@ -13,7 +13,9 @@ import { RPCBridgeAdapter } from '../adapters/rpc-bridge-adapter'; import { ConnectionInfo } from '../types/connection-info'; import logger from './logger'; import { IHostApplicationAdapter } from '../types/host-application-adapter'; -import { errorCodes } from '@metamask/rpc-errors'; +import { errorCodes, providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../Engine'; +import NavigationService from '../../NavigationService'; /** * Connection is a live, runtime representation of a dApp connection. @@ -36,9 +38,40 @@ export class Connection { this.hostApp = hostApp; this.bridge = new RPCBridgeAdapter(this.info); - this.client.on('message', (payload) => { + this.client.on('message', async (payload) => { logger.debug('Received message:', this.id, payload); + const isWalletCreateSessionRequest = + payload && + typeof payload === 'object' && + 'name' in payload && + payload.name === 'metamask-multichain-provider' && + 'data' in payload && + payload.data && + typeof payload.data === 'object' && + 'method' in payload.data && + payload.data.method === 'wallet_createSession'; + + // If the request is a wallet_createSession request and there are pending approval requests, clear those pending approvals before + // showing the wallet_createSession approval. We do this to prevent the user from seeing a stale wallet_createSession approval in the + // scenario where they make a connection request, but leave the wallet before approving or rejecting the request, return to the dapp + // to make a new connection request, and then finally return to the wallet to approve or reject the new connection request. + if ( + isWalletCreateSessionRequest && + Engine.context.ApprovalController.getTotalApprovalCount() > 0 + ) { + // We must manually navigate away from the currently open approval request, otherwise an approval component may be rendered + // with an approval request prop that it cannot handle and cause the wallet to throw an exception. + NavigationService.navigation?.goBack(); + await Engine.context.ApprovalController.clear( + providerErrors.userRejectedRequest({ + data: { + cause: 'rejectAllApprovals', + }, + }), + ); + } + this.bridge.send(payload); }); diff --git a/app/selectors/featureFlagController/fullPageAccountList/index.test.ts b/app/selectors/featureFlagController/fullPageAccountList/index.test.ts new file mode 100644 index 000000000000..6552c16f48e4 --- /dev/null +++ b/app/selectors/featureFlagController/fullPageAccountList/index.test.ts @@ -0,0 +1,128 @@ +import { + selectFullPageAccountListEnabledRawFlag, + selectFullPageAccountListEnabledFlag, + FULL_PAGE_ACCOUNT_LIST_FLAG_NAME, +} from '.'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('Full Page Account List Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectFullPageAccountListEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectFullPageAccountListEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + true, + true, + ); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + true, + false, + ); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + false, + true, + ); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + false, + false, + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/fullPageAccountList/index.ts b/app/selectors/featureFlagController/fullPageAccountList/index.ts new file mode 100644 index 000000000000..162f9f69bd1d --- /dev/null +++ b/app/selectors/featureFlagController/fullPageAccountList/index.ts @@ -0,0 +1,47 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED = false; +export const FULL_PAGE_ACCOUNT_LIST_FLAG_NAME = 'fullPageAccountList'; + +/** + * Selector for the raw full page account list remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectFullPageAccountListEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, FULL_PAGE_ACCOUNT_LIST_FLAG_NAME)) { + return DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + FULL_PAGE_ACCOUNT_LIST_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED + ); + }, +); + +/** + * Selector for the full page account list enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectFullPageAccountListEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectFullPageAccountListEnabledRawFlag, + (isBasicFunctionalityEnabled, fullPageAccountListEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return fullPageAccountListEnabledRawFlag; + }, +); diff --git a/app/selectors/featureFlagController/otaUpdates/index.test.ts b/app/selectors/featureFlagController/otaUpdates/index.test.ts new file mode 100644 index 000000000000..bac3b3e31eaa --- /dev/null +++ b/app/selectors/featureFlagController/otaUpdates/index.test.ts @@ -0,0 +1,116 @@ +import { + selectOtaUpdatesEnabledRawFlag, + selectOtaUpdatesEnabledFlag, + OTA_UPDATES_FLAG_NAME, +} from '.'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('OTA Updates Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectOtaUpdatesEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectOtaUpdatesEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/otaUpdates/index.ts b/app/selectors/featureFlagController/otaUpdates/index.ts new file mode 100644 index 000000000000..619fc97ac1d2 --- /dev/null +++ b/app/selectors/featureFlagController/otaUpdates/index.ts @@ -0,0 +1,47 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_OTA_UPDATES_ENABLED = false; +export const OTA_UPDATES_FLAG_NAME = 'otaUpdatesEnabled'; + +/** + * Selector for the raw OTA updates enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectOtaUpdatesEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, OTA_UPDATES_FLAG_NAME)) { + return DEFAULT_OTA_UPDATES_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + OTA_UPDATES_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_OTA_UPDATES_ENABLED + ); + }, +); + +/** + * Selector for the OTA updates enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectOtaUpdatesEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectOtaUpdatesEnabledRawFlag, + (isBasicFunctionalityEnabled, otaUpdatesEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return otaUpdatesEnabledRawFlag; + }, +); diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts index 275ae27c7cf3..e483f109a1b1 100644 --- a/app/selectors/featureFlagController/rewards/index.ts +++ b/app/selectors/featureFlagController/rewards/index.ts @@ -6,6 +6,16 @@ import { VersionGatedFeatureFlag, } from '../../../util/remoteFeatureFlag'; +// Re-export selectors from rewardsEnabled.ts +export { + selectRewardsEnabledFlag, + selectRewardsEnabledRawFlag, + selectMusdHoldingEnabledFlag, + selectMusdHoldingEnabledRawFlag, + REWARDS_ENABLED_FLAG_NAME, + MUSD_HOLDING_FLAG_NAME, +} from './rewardsEnabled'; + const DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED = false; const DEFAULT_CARD_SPEND_ENABLED = false; const DEFAULT_MUSD_DEPOSIT_ENABLED = false; diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts new file mode 100644 index 000000000000..1cae6ec674af --- /dev/null +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts @@ -0,0 +1,207 @@ +import { + selectRewardsEnabledRawFlag, + selectRewardsEnabledFlag, + selectMusdHoldingEnabledRawFlag, + selectMusdHoldingEnabledFlag, + REWARDS_ENABLED_FLAG_NAME, + MUSD_HOLDING_FLAG_NAME, +} from './rewardsEnabled'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('Rewards Enabled Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectRewardsEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectRewardsEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectRewardsEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectRewardsEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectRewardsEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectRewardsEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); + + describe('selectMusdHoldingEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectMusdHoldingEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts new file mode 100644 index 000000000000..2477539ef5c2 --- /dev/null +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts @@ -0,0 +1,85 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_REWARDS_ENABLED = false; +export const REWARDS_ENABLED_FLAG_NAME = 'rewardsEnabled'; + +export const MUSD_HOLDING_FLAG_NAME = 'rewardsEnableMusdHolding'; +const DEFAULT_MUSD_HOLDING_ENABLED = false; + +/** + * Selector for the raw rewards enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectRewardsEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, REWARDS_ENABLED_FLAG_NAME)) { + return DEFAULT_REWARDS_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + REWARDS_ENABLED_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED + ); + }, +); + +/** + * Selector for the rewards enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectRewardsEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectRewardsEnabledRawFlag, + (isBasicFunctionalityEnabled, rewardsEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return rewardsEnabledRawFlag; + }, +); + +/** + * Selector for the raw mUSD holding enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectMusdHoldingEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, MUSD_HOLDING_FLAG_NAME)) { + return DEFAULT_MUSD_HOLDING_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + MUSD_HOLDING_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_MUSD_HOLDING_ENABLED + ); + }, +); + +/** + * Selector for the mUSD holding enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectMusdHoldingEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectMusdHoldingEnabledRawFlag, + (isBasicFunctionalityEnabled, musdHoldingEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return musdHoldingEnabledRawFlag; + }, +); diff --git a/app/selectors/tokenList.test.ts b/app/selectors/tokenList.test.ts deleted file mode 100644 index bfa85950cb09..000000000000 --- a/app/selectors/tokenList.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { selectSortedTokenKeys } from './tokenList'; -import { selectTokenSortConfig } from './preferencesController'; -import { selectIsEvmNetworkSelected } from './multichainNetworkController'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { selectSelectedInternalAccount } from './accountsController'; -///: END:ONLY_INCLUDE_IF - -import { - selectEvmTokens, - selectEvmTokenFiatBalances, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectMultichainTokenListForAccountId, - ///: END:ONLY_INCLUDE_IF -} from './multichain'; -import { InternalAccount } from '@metamask/keyring-internal-api'; -import { TokenI } from '../components/UI/Tokens/types'; -import { RootState } from '../reducers'; - -jest.mock('./preferencesController'); -jest.mock('./multichainNetworkController'); -jest.mock('./accountsController'); -jest.mock('./multichain', () => ({ - selectEvmTokens: jest.fn(), - selectEvmTokenFiatBalances: jest.fn(), - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectMultichainTokenListForAccountId: jest.fn(), - ///: END:ONLY_INCLUDE_IF -})); -jest.mock('../store', () => ({ - store: { getState: jest.fn() }, -})); - -// This selector consumes many selectors and is very hard to create exact state -// So instead uses mocks to simulate the internal selector changes -describe('selectSortedTokenKeys', () => { - const mockState = () => ({}) as unknown as RootState; - - const createEvmTokens = (tokenAddrs: string[]) => - tokenAddrs.map( - (address) => - ({ - address, - chainId: '0x1', - isStaked: false, - }) as TokenI, - ); - - const createNonEvmTokens = (tokenAddrs: string[]) => - tokenAddrs.map( - (address, idx) => - ({ - address, - chainId: '0x1337', - isStaked: undefined, - balanceFiat: idx + 10, - }) as unknown as ReturnType< - typeof selectMultichainTokenListForAccountId - >[number], - ); - - const arrangeMocks = () => { - const mockSelectTokenSortConfig = jest - .mocked(selectTokenSortConfig) - .mockReturnValue({ - key: 'tokenFiatAmount', - order: 'dsc', - sortCallback: 'stringNumeric', - }); - - const mockSelectIsEvmNetworkSelected = jest - .mocked(selectIsEvmNetworkSelected) - .mockReturnValue(true); - - const mockEvmTokens = createEvmTokens([ - 'tokenAddr1', - 'tokenAddr2', - 'tokenAddr3', - ]); - const mockSelectEvmTokens = jest - .mocked(selectEvmTokens) - .mockReturnValue(mockEvmTokens); - - const mockEvmTotalFiatBalance = mockEvmTokens.map((_, idx) => idx + 1); - - const mockSelectEvmTokenFiatBalances = jest - .mocked(selectEvmTokenFiatBalances) - .mockReturnValue(mockEvmTotalFiatBalance); - - const mockSelectSelectedInternalAccount = jest - .mocked(selectSelectedInternalAccount) - .mockReturnValue({ id: 'account1' } as InternalAccount); - - const mockNonEvmTokens = createNonEvmTokens([ - 'tokenAddrA', - 'tokenAddrB', - 'tokenAddrC', - ]); - const mockSelectMultichainTokenListForAccountId = jest - .mocked(selectMultichainTokenListForAccountId) - .mockReturnValue(mockNonEvmTokens); - - return { - mockSelectTokenSortConfig, - mockSelectIsEvmNetworkSelected, - mockSelectEvmTokens, - mockSelectEvmTokenFiatBalances, - mockSelectSelectedInternalAccount, - mockSelectMultichainTokenListForAccountId, - }; - }; - - // Setup mocks - beforeEach(() => { - jest.clearAllMocks(); - selectSortedTokenKeys.resetRecomputations(); - }); - - it('returns an array of ordered evm token keys', () => { - const { mockSelectEvmTokens, mockSelectEvmTokenFiatBalances } = - arrangeMocks(); - - // Arrange - setup tokens - mockSelectEvmTokens.mockReturnValue(createEvmTokens(['0x1', '0x2', '0x3'])); - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - - const result = selectSortedTokenKeys(mockState()); - expect(result.map((r) => r.address)).toStrictEqual(['0x3', '0x2', '0x1']); - }); - - it('returns an array of ordered non-evm token keys', () => { - const { - mockSelectIsEvmNetworkSelected, - mockSelectMultichainTokenListForAccountId, - } = arrangeMocks(); - - mockSelectIsEvmNetworkSelected.mockReturnValueOnce(false); - - // Arrange - setup tokens - const nonEvmTokens = createNonEvmTokens(['0x4', '0x5', '0x6']); - nonEvmTokens[0].balanceFiat = '4'; - nonEvmTokens[1].balanceFiat = '5'; - nonEvmTokens[2].balanceFiat = '6'; - mockSelectMultichainTokenListForAccountId.mockReturnValue(nonEvmTokens); - - const result = selectSortedTokenKeys(mockState()); - expect(result.map((r) => r.address)).toStrictEqual(['0x6', '0x5', '0x4']); - }); - - it('returns the exact same result when input values/selectors are the same', () => { - arrangeMocks(); - const result1 = selectSortedTokenKeys(mockState()); - const result2 = selectSortedTokenKeys(mockState()); - expect(result1).toBe(result2); - }); - - it('returns the exact same result when evm fiat fluctuates a tiny bit', () => { - const { mockSelectEvmTokenFiatBalances } = arrangeMocks(); - - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - const result1 = selectSortedTokenKeys(mockState()); - - // fiat values changed, but order remains the same - mockSelectEvmTokenFiatBalances.mockReturnValue([1.1, 2.2, 3.3]); - const result2 = selectSortedTokenKeys(mockState()); - - expect(result1).toBe(result2); - }); - - it('returns a new list or sorted keys when evm fiat changes order', () => { - const { mockSelectEvmTokenFiatBalances } = arrangeMocks(); - - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - const result1 = selectSortedTokenKeys(mockState()); - - // fiat values changed drastically, order has changed - mockSelectEvmTokenFiatBalances.mockReturnValue([3, 2, 1]); - const result2 = selectSortedTokenKeys(mockState()); - - expect(result1).not.toBe(result2); - }); -}); diff --git a/app/selectors/tokenList.ts b/app/selectors/tokenList.ts deleted file mode 100644 index 6d42bccf710c..000000000000 --- a/app/selectors/tokenList.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createSelector } from 'reselect'; -import { selectTokenSortConfig } from './preferencesController'; -import { selectIsEvmNetworkSelected } from './multichainNetworkController'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { selectSelectedInternalAccount } from './accountsController'; -///: END:ONLY_INCLUDE_IF - -import { - selectEvmTokens, - selectEvmTokenFiatBalances, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectMultichainTokenListForAccountId, - ///: END:ONLY_INCLUDE_IF -} from './multichain'; -import { RootState } from '../reducers'; -import { TokenI } from '../components/UI/Tokens/types'; -import { sortAssets } from '../components/UI/Tokens/util'; -import { TraceName, endTrace, trace } from '../util/trace'; -import { getTraceTags } from '../util/sentry/tags'; -import { store } from '../store'; -import { createDeepEqualSelector } from './util'; - -const _selectSortedTokenKeys = createSelector( - [ - selectEvmTokens, - selectEvmTokenFiatBalances, - selectIsEvmNetworkSelected, - selectTokenSortConfig, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - (state: RootState) => { - const selectedAccount = selectSelectedInternalAccount(state); - return selectMultichainTokenListForAccountId(state, selectedAccount?.id); - }, - ///: END:ONLY_INCLUDE_IF - ], - ( - evmTokens, - tokenFiatBalances, - isEvmSelected, - tokenSortConfig, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - nonEvmTokens, - ///: END:ONLY_INCLUDE_IF - ) => { - trace({ - name: TraceName.Tokens, - tags: getTraceTags(store.getState()), - }); - - const tokenListData = isEvmSelected ? evmTokens : nonEvmTokens; - - const tokensWithBalances: TokenI[] = tokenListData.map((token, i) => ({ - ...token, - tokenFiatAmount: isEvmSelected ? tokenFiatBalances[i] : token.balanceFiat, - })); - - const tokensSorted = sortAssets(tokensWithBalances, tokenSortConfig); - - endTrace({ name: TraceName.Tokens }); - - return tokensSorted.map(({ address, chainId, isStaked }) => ({ - address, - chainId, - isStaked, - })); - }, -); - -// Deep equal selector is necessary, because prices can change little bit but order of tokens stays the same. -// So if the previous keys are still valid (deep eq the current list), then we can use the memoized result -export const selectSortedTokenKeys = createDeepEqualSelector( - _selectSortedTokenKeys, - (keys) => keys.filter(({ address, chainId }) => address && chainId), -); diff --git a/e2e/api-mocking/mock-e2e-allowlist.ts b/e2e/api-mocking/mock-e2e-allowlist.ts index 72072325c7b6..179bcdf0f78a 100644 --- a/e2e/api-mocking/mock-e2e-allowlist.ts +++ b/e2e/api-mocking/mock-e2e-allowlist.ts @@ -39,7 +39,6 @@ export const ALLOWLISTED_URLS = [ 'https://mainnet.era.zksync.io/', 'https://eth.llamarpc.com/', 'https://rpc.atlantischain.network/', - 'https://rewards.dev-api.cx.metamask.io/auth/mobile-login', 'https://nft.api.cx.metamask.io/collections?chainId=0x539&contract=0xb2552e4f4bc23e1572041677234d192774558bf0', 'https://metamask.github.io/test-dapp/metamask-fox.svg', 'https://dapp-scanning.api.cx.metamask.io/bulk-scan', diff --git a/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts b/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts new file mode 100644 index 000000000000..ec57ccb2160a --- /dev/null +++ b/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts @@ -0,0 +1,55 @@ +import { MockEventsObject } from '../../../framework'; + +export const CONTENTFUL_BANNERS_MOCKS: MockEventsObject = { + GET: [ + { + urlEndpoint: + /https:\/\/cdn\.contentful\.com.*content_type=promotionalBanner/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + { + urlEndpoint: /contentful\.com.*promotionalBanner/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + { + urlEndpoint: /contentful\.com.*showInMobile.*true/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + ], +}; diff --git a/e2e/api-mocking/mock-responses/defaults/index.ts b/e2e/api-mocking/mock-responses/defaults/index.ts index 3f471d824cb8..85c82fbc477b 100644 --- a/e2e/api-mocking/mock-responses/defaults/index.ts +++ b/e2e/api-mocking/mock-responses/defaults/index.ts @@ -25,6 +25,7 @@ import { INFURA_MOCKS } from '../infura-mocks'; import { CHAINS_NETWORK_MOCK_RESPONSE } from '../chains-network-mocks'; import { DEFAULT_REWARDS_MOCKS } from './rewards'; import { ACL_EXECUTION_MOCKS } from './acl-execution'; +import { CONTENTFUL_BANNERS_MOCKS } from './contentful-banners'; // Get auth mocks const authMocks = getAuthMocks(); @@ -49,6 +50,7 @@ export const DEFAULT_MOCKS = { ...(INFURA_MOCKS.GET || []), ...(DEFAULT_REWARDS_MOCKS.GET || []), ...(ACL_EXECUTION_MOCKS.GET || []), + ...(CONTENTFUL_BANNERS_MOCKS.GET || []), // Chains Network Mock - Provides blockchain network data { urlEndpoint: 'https://chainid.network/chains.json', diff --git a/e2e/api-mocking/mock-responses/defaults/rewards.ts b/e2e/api-mocking/mock-responses/defaults/rewards.ts index 2226e06236c4..96bbc3d1320e 100644 --- a/e2e/api-mocking/mock-responses/defaults/rewards.ts +++ b/e2e/api-mocking/mock-responses/defaults/rewards.ts @@ -6,7 +6,15 @@ import { MockEventsObject } from '../../../framework'; */ export const DEFAULT_REWARDS_MOCKS: MockEventsObject = { - GET: [ + POST: [ + { + urlEndpoint: + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/auth\/mobile-login$/, + responseCode: 401, + response: { + error: 'Unauthorized', + }, + }, { urlEndpoint: /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/rewards\/ois$/, @@ -16,14 +24,22 @@ export const DEFAULT_REWARDS_MOCKS: MockEventsObject = { }, }, ], - POST: [ + GET: [ { urlEndpoint: - /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/auth\/mobile-login$/, - responseCode: 401, + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/seasons\/status$/, + responseCode: 200, response: { - error: 'Unauthorized', + previous: null, + current: {}, + next: null, }, }, + { + urlEndpoint: + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/seasons\/[a-f0-9-]+\/metadata$/, + responseCode: 200, + response: {}, + }, ], }; diff --git a/e2e/framework/fixtures/FixtureHelper.ts b/e2e/framework/fixtures/FixtureHelper.ts index 5c32d5df30ca..73b8f43d612c 100644 --- a/e2e/framework/fixtures/FixtureHelper.ts +++ b/e2e/framework/fixtures/FixtureHelper.ts @@ -660,6 +660,35 @@ export async function withFixtures( } } + // skipReactNativeReload needs to happen before killing the mock server to avoid race conditions + if (!skipReactNativeReload) { + try { + // Disable synchronization to prevent race conditions with pending timers + await device.disableSynchronization(); + await device.reloadReactNative(); + await device.enableSynchronization(); + } catch (cleanupError) { + logger.warn('React Native reload failed (non-critical):', cleanupError); + // Ensure synchronization is re-enabled even on failure + try { + await device.enableSynchronization(); + } catch { + // Ignore - best effort + } + // Don't add to cleanupErrors as this is a non-critical cleanup operation + } + } + + if (mockServerInstance) { + try { + // Validate live requests + mockServerInstance.validateLiveRequests(); + } catch (cleanupError) { + logger.error('Error during live request validation:', cleanupError); + cleanupErrors.push(cleanupError as Error); + } + } + // Clean up the mock server if (mockServerInstance?.isStarted()) { try { @@ -695,26 +724,6 @@ export async function withFixtures( } } - if (!skipReactNativeReload) { - try { - // Force reload React Native to stop any lingering timers - await device.reloadReactNative(); - } catch (cleanupError) { - logger.warn('React Native reload failed (non-critical):', cleanupError); - // Don't add to cleanupErrors as this is a non-critical cleanup operation - } - } - - if (mockServerInstance) { - try { - // Validate live requests - mockServerInstance.validateLiveRequests(); - } catch (cleanupError) { - logger.error('Error during live request validation:', cleanupError); - cleanupErrors.push(cleanupError as Error); - } - } - // Handle error reporting: prioritize test error over cleanup errors if (testError && cleanupErrors.length > 0) { // Both test and cleanup failed - report both but throw the test error diff --git a/locales/languages/de.json b/locales/languages/de.json index 6ed4886a3cf2..a46210dd1418 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -7204,7 +7204,7 @@ "description": "Verbindungsherstellung fehlgeschlagen. Bitte versuchen Sie es erneut." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Alle Netzwerke", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prognosen", "no_results": "No results found", "sites": "Websites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/el.json b/locales/languages/el.json index cfa4bf705d1f..22c4d2bae161 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -7204,7 +7204,7 @@ "description": "Απέτυχε η προσπάθεια σύνδεσης. Παρακαλώ δοκιμάστε ξανά." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Όλα τα δίκτυα", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Προβλέψεις", "no_results": "No results found", "sites": "Ιστότοποι", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/en.json b/locales/languages/en.json index 8c2598e25e51..7169b2ed7f07 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6999,7 +6999,7 @@ } }, "settings": { - "title": "Rewards Settings", + "title": "Rewards settings", "subtitle": "Accounts", "description": "Add multiple accounts to combine your points and unlock rewards faster.", "tab_linked_accounts": "Added ({{count}})", @@ -7047,7 +7047,7 @@ }, "ways_to_earn": { "title": "Ways to earn", - "supported_networks": "Supported Networks", + "supported_networks": "Supported networks", "swap": { "title": "Swap", "description": "8 points per $10", @@ -7104,7 +7104,7 @@ "title": "MetaMask Card", "points": "1 point per $1 spent", "description": "Earn points every time you use your MetaMask Card for purchases, plus 1% cash back (3% for Metal cardholders).", - "cta_label": "Manage Card" + "cta_label": "Manage card" } }, "deposit_musd": { @@ -7179,7 +7179,7 @@ }, "transaction_details": { "title": { - "perps_deposit": "Funded Perps account", + "perps_deposit": "Funded perps account", "predict_claim": "Claimed winnings", "predict_deposit": "Funded Predict account", "predict_withdraw": "Withdrawal", @@ -7198,9 +7198,9 @@ "bridge_approval": "Approve {{approveSymbol}}", "bridge_approval_loading": "Approve", "bridge_send": "Bridge {{sourceSymbol}} from {{sourceChain}}", - "bridge_send_loading": "Bridge Send", + "bridge_send_loading": "Bridge send", "bridge_receive": "Receive {{targetSymbol}} on {{targetChain}}", - "bridge_receive_loading": "Bridge Receive", + "bridge_receive_loading": "Bridge receive", "default": "Transaction", "perps_deposit": "Add funds", "predict_deposit": "Add funds", @@ -7215,11 +7215,11 @@ "description": "Establishing connection with {{dappName}}..." }, "show_error": { - "title": "Connection Error", + "title": "Connection error", "description": "Failed to establish connection. Please try again." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7242,7 +7242,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "All networks", "24h": "24h", @@ -7264,7 +7264,7 @@ "predictions": "Predictions", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/es.json b/locales/languages/es.json index 8db0089932bd..206fa2736e6c 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -7204,7 +7204,7 @@ "description": "No se pudo establecer la conexión. Inténtalo de nuevo." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Todas las redes", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Predicciones", "no_results": "No results found", "sites": "Sitios", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index d9f71453efd2..e4451d60fd74 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -7204,7 +7204,7 @@ "description": "La connexion a échoué. Veuillez réessayer." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Jetons", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tous les réseaux", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prédictions", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index a86cdaca1854..4fbb2669b802 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -7204,7 +7204,7 @@ "description": "कनेक्शन स्थापित करना नहीं हो पाया। कृपया फिर से प्रयास करें।" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "टोकन", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "सभी नेटवर्क", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "प्रेडिक्शंस", "no_results": "No results found", "sites": "साइट्स", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/id.json b/locales/languages/id.json index 0e71a65d1337..ed88f9395d57 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -7204,7 +7204,7 @@ "description": "Gagal membuat koneksi. Coba lagi." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Semua jaringan", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prediksi", "no_results": "No results found", "sites": "Situs", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index e82a80291d2e..e584c3b45350 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -7204,7 +7204,7 @@ "description": "接続の確立に失敗しました。もう一度お試しください。" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "トークン", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "すべてのネットワーク", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "予測", "no_results": "No results found", "sites": "サイト", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index e61a63767753..350bf123982d 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -7204,7 +7204,7 @@ "description": "연결하는 데 실패했습니다. 다시 시도하세요." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "토큰", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "모든 네트워크", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "예측", "no_results": "No results found", "sites": "사이트", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 2624166b1139..94fca005c3e7 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -7204,7 +7204,7 @@ "description": "Não foi possível estabelecer a conexão. Tente novamente." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Todas as redes", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Previsões", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 06a3e8c58100..37edee8c2a7c 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -7204,7 +7204,7 @@ "description": "Не удалось установить соединение. Попробуйте ещё раз." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Токены", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Все сети", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Прогнозы", "no_results": "No results found", "sites": "Сайты", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index f26529913a71..412136527457 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -7204,7 +7204,7 @@ "description": "Nabigong maitatag ang koneksyon. Pakisubukan ulit." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Mga Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Lahat ng network", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Mga hula", "no_results": "No results found", "sites": "Mga Site", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index a40cd2df30f3..00a5cf149660 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -7204,7 +7204,7 @@ "description": "Bağlantı kurulamadı. Lütfen tekrar deneyin." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token'lar", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tüm ağlar", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Tahminler", "no_results": "No results found", "sites": "Siteler", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 272e1f1e9f52..337f0c1a6322 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -7204,7 +7204,7 @@ "description": "Không thể thiết lập kết nối. Vui lòng thử lại." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tất cả mạng", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Dự đoán", "no_results": "No results found", "sites": "Trang web", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 035a740086d5..c139592c73e0 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -7204,7 +7204,7 @@ "description": "建立连接失败。请重试。" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "代币", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "所有网络", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "预测", "no_results": "No results found", "sites": "网站", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/package.json b/package.json index 33b7b176f5ff..3842a5890957 100644 --- a/package.json +++ b/package.json @@ -176,12 +176,11 @@ "@ethereumjs/util@npm:^9.0.2": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", "@metamask/key-tree@npm:^10.1.1": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/key-tree@npm:^10.0.2": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", - "@metamask/transaction-controller@npm:^62.6.0": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/bridge-controller@npm:^64.0.0": "patch:@metamask/bridge-controller@npm%3A61.0.0#~/.yarn/patches/@metamask-bridge-controller-npm-61.0.0-8c413c463f.patch" + "@metamask/transaction-controller@npm:^62.7.0": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", - "@consensys/native-ramps-sdk": "2.1.6", + "@consensys/native-ramps-sdk": "^2.1.7", "@consensys/on-ramp-sdk": "2.1.12", "@craftzdog/react-native-buffer": "^6.1.0", "@ethersproject/abi": "^5.7.0", @@ -201,7 +200,7 @@ "@metamask/assets-controllers": "^94.1.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.8.0", - "@metamask/bridge-controller": "^64.1.0", + "@metamask/bridge-controller": "^64.2.0", "@metamask/bridge-status-controller": "^64.0.1", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/composable-controller": "^12.0.0", @@ -289,7 +288,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", "@metamask/token-search-discovery-controller": "^4.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.5.0", "@metamask/tron-wallet-snap": "^1.16.1", "@metamask/utils": "^11.8.1", diff --git a/yarn.lock b/yarn.lock index fbcb0d36c8e2..062a31597455 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2049,9 +2049,9 @@ __metadata: languageName: node linkType: hard -"@consensys/native-ramps-sdk@npm:2.1.6": - version: 2.1.6 - resolution: "@consensys/native-ramps-sdk@npm:2.1.6" +"@consensys/native-ramps-sdk@npm:^2.1.7": + version: 2.1.7 + resolution: "@consensys/native-ramps-sdk@npm:2.1.7" dependencies: "@metamask/utils": "npm:^11.5.0" async: "npm:^3.2.3" @@ -2060,7 +2060,7 @@ __metadata: crypto-js: "npm:^4.2.0" reflect-metadata: "npm:^0.1.13" uuid: "npm:^9.0.0" - checksum: 10/0d98a744366dcc7a0b6954540c1c5abc5783a12692debb17363c773277793f327d669d9ddf20e596126ab41affa504faf07a677db445078facff9abbe603fd9d + checksum: 10/52c8a7911861dce0b2828c1dee039ffd40934e343bdda4a29b742cc104919c477f033dea95b6686a7cbf03c4ef21bcf1c7617ae000001d617fc618189d6cabc4 languageName: node linkType: hard @@ -7320,9 +7320,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^64.1.0": - version: 64.1.0 - resolution: "@metamask/bridge-controller@npm:64.1.0" +"@metamask/bridge-controller@npm:^64.1.0, @metamask/bridge-controller@npm:^64.2.0": + version: 64.2.0 + resolution: "@metamask/bridge-controller@npm:64.2.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7330,7 +7330,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/assets-controllers": "npm:^93.1.0" + "@metamask/assets-controllers": "npm:^94.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" @@ -7342,33 +7342,33 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.0" "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" - "@metamask/transaction-controller": "npm:^62.5.0" + "@metamask/transaction-controller": "npm:^62.7.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/b5019e54b79e89da5271b43309074ce43dc831dc01a5acc028c3acc9a8655f842d6d0b74092a0ddab9e4db3c622dd31280af6cedc179fdc0af970b7373ba4474 + checksum: 10/3669dca650e7b0424a55c852f1cb4f1c73a4e3e5554b1b1311f5ec9aa3e13eb4d752a90851b7d40de876bfdcc42b325d355d07fbeb6cee471bd362c0044d762b languageName: node linkType: hard "@metamask/bridge-status-controller@npm:^64.0.1, @metamask/bridge-status-controller@npm:^64.1.0": - version: 64.1.0 - resolution: "@metamask/bridge-status-controller@npm:64.1.0" + version: 64.2.0 + resolution: "@metamask/bridge-status-controller@npm:64.2.0" dependencies: "@metamask/accounts-controller": "npm:^35.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^64.1.0" + "@metamask/bridge-controller": "npm:^64.2.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.5.0" + "@metamask/transaction-controller": "npm:^62.7.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" uuid: "npm:^8.3.2" - checksum: 10/b7445e9cd0997b3ef46e71003f608705281d38a0ad710aa5aaeac69915f738b70f7d73b66d37501f66d28c1ae03fccbf703863e9dd24383d78cac10805a9d9cc + checksum: 10/f707ea4ba3d52e2231025e24a8923121881fa303a4d1ab40ee5405fb62fa791bef0271ae4117afe60936dd4e8326815acd4b3806ea46869ba9dab91c43ce1d9d languageName: node linkType: hard @@ -9469,9 +9469,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:62.6.0, @metamask/transaction-controller@npm:^62.4.0, @metamask/transaction-controller@npm:^62.5.0": - version: 62.6.0 - resolution: "@metamask/transaction-controller@npm:62.6.0" +"@metamask/transaction-controller@npm:62.7.0, @metamask/transaction-controller@npm:^62.4.0, @metamask/transaction-controller@npm:^62.6.0": + version: 62.7.0 + resolution: "@metamask/transaction-controller@npm:62.7.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9503,7 +9503,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/d02731b018ee575dd9a8ca3529f9296cda51e9bf939628c7846c37a4cc024fdf41960393489c203c30c09730c68016be2c98619fcd60aaa3f24db7921069fc00 + checksum: 10/f9b34194b4e9bf775f66256da6fe0908854346da348238d122856a3bae3621e6ccafab273ed6c4f2b175848a2d74f0257a9f98a6efc6ff14f19b8d37bc256737 languageName: node linkType: hard @@ -9545,9 +9545,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": - version: 62.6.0 - resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.6.0&hash=1a3342" +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": + version: 62.7.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.7.0&hash=1a3342" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9579,7 +9579,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/b75b4a26082fb59a5a58bc8761471961d5ebab4529020005030ef28010c1dac6f6ba893c6777b66c85d8dd096625ff379515656166ffce619156fa52d8a8bc5b + checksum: 10/07f3ac5bcb5b47c1b056ba6ad444c5dfd87cfb1246d3aeab02e41635a03f0860c73fa6f4726bf9c561c98d198372ca48e5fb44ead0cfea4f8493952b38a0f863 languageName: node linkType: hard @@ -34134,7 +34134,7 @@ __metadata: "@babel/register": "npm:^7.24.6" "@babel/runtime": "npm:^7.25.0" "@config-plugins/detox": "npm:^9.0.0" - "@consensys/native-ramps-sdk": "npm:2.1.6" + "@consensys/native-ramps-sdk": "npm:^2.1.7" "@consensys/on-ramp-sdk": "npm:2.1.12" "@craftzdog/react-native-buffer": "npm:^6.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -34163,7 +34163,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.8.0" - "@metamask/bridge-controller": "npm:^64.1.0" + "@metamask/bridge-controller": "npm:^64.2.0" "@metamask/bridge-status-controller": "npm:^64.0.1" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" @@ -34262,7 +34262,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/token-search-discovery-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.5.0" "@metamask/tron-wallet-snap": "npm:^1.16.1" "@metamask/utils": "npm:^11.8.1"