diff --git a/app/component-library/hooks/useStyles.ts b/app/component-library/hooks/useStyles.ts index 60ec75c6c1a..2f53b4338f4 100644 --- a/app/component-library/hooks/useStyles.ts +++ b/app/component-library/hooks/useStyles.ts @@ -7,17 +7,31 @@ import { Theme } from '../../util/theme/models'; * Hook that handles both passing style sheet variables into style sheet and memoization. * * @param styleSheet Return value of useStyles hook. - * @param vars Variables of styleSheet function. + * @param vars Variables of styleSheet function (optional). * @returns StyleSheet object. */ -export const useStyles = ( +// Overload: when vars is provided +export function useStyles( styleSheet: (params: { theme: Theme; vars: V }) => R, vars: V, -): { styles: R; theme: Theme } => { +): { styles: R; theme: Theme }; + +// Overload: when vars is not provided +export function useStyles(styleSheet: (params: { theme: Theme }) => R): { + styles: R; + theme: Theme; +}; + +export function useStyles( + styleSheet: + | ((params: { theme: Theme; vars: V }) => R) + | ((params: { theme: Theme }) => R), + vars?: V, +): { styles: R; theme: Theme } { const theme = useAppThemeFromContext(); const styles = useMemo( - () => styleSheet({ theme, vars }), + () => styleSheet({ theme, vars: vars as V }), [styleSheet, theme, vars], ); return { styles, theme }; -}; +} diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 0ca83a2465b..ccff75cec0e 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -23,13 +23,14 @@ import * as NavigationNative from '@react-navigation/native'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { mockTheme, ThemeContext } from '../../../util/theme'; -import { Linking } from 'react-native'; +import { Linking, View as MockView } from 'react-native'; import StorageWrapper from '../../../store/storage-wrapper'; import { Authentication } from '../../../core'; import { internalAccount1 as mockAccount } from '../../../util/test/accountsControllerTestUtils'; import { KeyringTypes } from '@metamask/keyring-controller'; import { AccountDetailsIds } from '../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors'; import { AvatarAccountType } from '../../../component-library/components/Avatars/Avatar'; +import { useOTAUpdates } from '../../hooks/useOTAUpdates'; const initialState: DeepPartial = { user: { @@ -40,6 +41,8 @@ const initialState: DeepPartial = { }, }; +const MOCK_FOX_LOADER_ID = 'FOX_LOADER_ID'; + jest.mock('react-native/Libraries/Linking/Linking', () => ({ addEventListener: jest.fn(), removeEventListener: jest.fn(), @@ -74,6 +77,24 @@ jest.mock('../../hooks/useMetrics/useMetrics', () => ({ }), })); +jest.mock('../../hooks/useOTAUpdates', () => ({ + useOTAUpdates: jest.fn().mockReturnValue({ + isCheckingUpdates: false, + }), +})); + +const mockUseOTAUpdates = useOTAUpdates as jest.MockedFunction< + typeof useOTAUpdates +>; + +jest.mock( + '../../UI/FoxLoader', + () => + function MockFoxLoader() { + return ; + }, +); + jest.mock('react-native-branch', () => ({ subscribe: jest.fn(), getLatestReferringParams: jest.fn(), @@ -238,6 +259,9 @@ describe('App', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseOTAUpdates.mockReturnValue({ + isCheckingUpdates: false, + }); mockNavigate.mockClear(); }); @@ -250,6 +274,20 @@ describe('App', () => { jest.useRealTimers(); }); + it('renders FoxLoader when OTA update check runs', () => { + mockUseOTAUpdates.mockReturnValue({ + isCheckingUpdates: true, + }); + + const { getByTestId } = renderScreen( + App, + { name: 'App' }, + { state: initialState }, + ); + + expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy(); + }); + it('configures MetaMetrics instance and identifies user on startup', async () => { renderScreen(App, { name: 'App' }, { state: initialState }); await waitFor(() => { diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 9fe3994f42a..0be79941d6a 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -148,6 +148,7 @@ import MultichainAccountActions from '../../Views/MultichainAccounts/sheets/Mult import useInterval from '../../hooks/useInterval'; import { Duration } from '@metamask/utils'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; +import { useOTAUpdates } from '../../hooks/useOTAUpdates'; import { SmartAccountUpdateModal } from '../../Views/confirmations/components/smart-account-update-modal'; import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import { useMetrics } from '../../hooks/useMetrics'; @@ -1072,7 +1073,7 @@ const AppFlow = () => { ); }; -const App: React.FC = () => { +const AppContent: React.FC = () => { const navigation = useNavigation(); const routes = useNavigationState((state) => state.routes); const { toastRef } = useContext(ToastContext); @@ -1257,4 +1258,14 @@ const App: React.FC = () => { ); }; +const App: React.FC = () => { + const { isCheckingUpdates } = useOTAUpdates(); + + if (isCheckingUpdates) { + return ; + } + + return ; +}; + export default App; diff --git a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap index 5db15946b87..666df4d831d 100644 --- a/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeDestTokenSelector/__snapshots__/BridgeDestTokenSelector.test.tsx.snap @@ -550,7 +550,7 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens "paddingHorizontal": 4, } } - handlerTag={1} + handlerTag={-1} handlerType="NativeViewGestureHandler" horizontal={true} onGestureHandlerEvent={[Function]} @@ -886,7 +886,13 @@ exports[`BridgeDestTokenSelector renders with initial state and displays tokens marginRight: 12, }, }); -export const BridgeDestTokenSelector: React.FC = () => { +export const BridgeDestTokenSelector: React.FC = React.memo(() => { const dispatch = useDispatch(); - const { styles } = useStyles(createStyles, {}); + const { styles } = useStyles(createStyles); const navigation = useNavigation(); const bridgeViewMode = useSelector(selectBridgeViewMode); @@ -59,11 +59,21 @@ export const BridgeDestTokenSelector: React.FC = () => { const selectedDestToken = useSelector(selectDestToken); const selectedDestChainId = useSelector(selectSelectedDestChainId); const selectedSourceToken = useSelector(selectSourceToken); + + const balanceChainIds = useMemo( + () => (selectedDestChainId ? [selectedDestChainId] : []), + [selectedDestChainId], + ); + const tokensToExclude = useMemo( + () => (selectedSourceToken ? [selectedSourceToken] : []), + [selectedSourceToken], + ); const { allTokens, tokensToRender, pending } = useTokens({ topTokensChainId: selectedDestChainId, - balanceChainIds: selectedDestChainId ? [selectedDestChainId] : [], - tokensToExclude: selectedSourceToken ? [selectedSourceToken] : [], + balanceChainIds, + tokensToExclude, }); + const handleTokenPress = useCallback( (token: BridgeToken) => { dispatch(setDestToken(token)); @@ -155,14 +165,18 @@ export const BridgeDestTokenSelector: React.FC = () => { ], ); + const networksBar = useMemo( + () => + bridgeViewMode === BridgeViewMode.Bridge || + bridgeViewMode === BridgeViewMode.Unified ? ( + + ) : undefined, + [bridgeViewMode], + ); + return ( - ) : undefined - } + networksBar={networksBar} renderTokenItem={renderToken} allTokens={allTokens} tokensToRender={tokensToRender} @@ -171,4 +185,6 @@ export const BridgeDestTokenSelector: React.FC = () => { scrollResetKey={selectedDestChainId} /> ); -}; +}); + +BridgeDestTokenSelector.displayName = 'BridgeDestTokenSelector'; diff --git a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap index 9390487b9be..c0a86645882 100644 --- a/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap +++ b/app/components/UI/Bridge/components/BridgeSourceTokenSelector/__snapshots__/BridgeSourceTokenSelector.test.tsx.snap @@ -891,7 +891,13 @@ exports[`BridgeSourceTokenSelector renders with initial state and displays token { +export const BridgeSourceTokenSelector: React.FC = React.memo(() => { const dispatch = useDispatch(); const navigation = useNavigation(); const bridgeViewMode = useSelector(selectBridgeViewMode); @@ -79,10 +79,14 @@ export const BridgeSourceTokenSelector: React.FC = () => { : undefined; } + const tokenToExclude = useMemo( + () => (selectedDestToken ? [selectedDestToken] : []), + [selectedDestToken], + ); const { allTokens, tokensToRender, pending } = useTokens({ topTokensChainId: selectedSourceToken?.chainId, balanceChainIds, - tokensToExclude: selectedDestToken ? [selectedDestToken] : [], + tokensToExclude: tokenToExclude, }); const handleTokenPress = useCallback( @@ -167,18 +171,28 @@ export const BridgeSourceTokenSelector: React.FC = () => { [selectedSourceChainIds, sortedSourceNetworks], ); + const networksBar = useMemo( + () => + isBridgeOrUnified ? ( + + ) : undefined, + [ + isBridgeOrUnified, + networksToShow, + allNetworkConfigurations, + selectedSourceChainIds, + enabledSourceChains, + ], + ); + return ( - ) : undefined - } + networksBar={networksBar} renderTokenItem={renderItem} allTokens={allTokens} tokensToRender={tokensToRender} @@ -186,4 +200,6 @@ export const BridgeSourceTokenSelector: React.FC = () => { chainIdToFetchMetadata={selectedChainId} /> ); -}; +}); + +BridgeSourceTokenSelector.displayName = 'BridgeSourceTokenSelector'; diff --git a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx index 297db92cb0a..ad2f7a74fd4 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelectorBase.tsx @@ -63,7 +63,7 @@ const createStyles = (params: { theme: Theme }) => { }); }; -export const SkeletonItem = () => { +export const SkeletonItem = React.memo(() => { const { styles } = useStyles(createStyles, {}); return ( @@ -81,9 +81,9 @@ export const SkeletonItem = () => { ); -}; +}); -export const LoadingSkeleton = () => { +export const LoadingSkeleton = React.memo(() => { const { styles } = useStyles(createStyles, {}); return ( @@ -97,7 +97,7 @@ export const LoadingSkeleton = () => { ); -}; +}); interface BridgeTokenSelectorBaseProps { networksBar: React.ReactNode; @@ -120,154 +120,156 @@ interface BridgeTokenSelectorBaseProps { scrollResetKey?: string | Hex | CaipChainId; } -export const BridgeTokenSelectorBase: React.FC< - BridgeTokenSelectorBaseProps -> = ({ - networksBar, - renderTokenItem, - allTokens, - tokensToRender, - pending, - chainIdToFetchMetadata: chainId, - title, - scrollResetKey, -}) => { - const { styles, theme } = useStyles(createStyles, {}); - const { - searchString, - setSearchString, - searchResults, - debouncedSearchString, - } = useTokenSearch({ - tokens: allTokens || [], - }); +export const BridgeTokenSelectorBase: React.FC = + React.memo( + ({ + networksBar, + renderTokenItem, + allTokens, + tokensToRender, + pending, + chainIdToFetchMetadata: chainId, + title, + scrollResetKey, + }) => { + const { styles, theme } = useStyles(createStyles); + const { + searchString, + setSearchString, + searchResults, + debouncedSearchString, + } = useTokenSearch({ + tokens: allTokens || [], + }); - const { - assetMetadata: unlistedAssetMetadata, - pending: unlistedAssetMetadataPending, - } = useAssetMetadata( - debouncedSearchString, - Boolean(debouncedSearchString && searchResults.length === 0), - chainId, - ); + const { + assetMetadata: unlistedAssetMetadata, + pending: unlistedAssetMetadataPending, + } = useAssetMetadata( + debouncedSearchString, + Boolean(debouncedSearchString && searchResults.length === 0), + chainId, + ); - const tokensToRenderWithSearch = useMemo(() => { - if (!searchString) { - return tokensToRender; - } + const tokensToRenderWithSearch = useMemo(() => { + if (!searchString) { + return tokensToRender; + } - if (searchResults.length > 0) { - return searchResults; - } + if (searchResults.length > 0) { + return searchResults; + } - return unlistedAssetMetadata ? [unlistedAssetMetadata] : []; - }, [searchString, searchResults, tokensToRender, unlistedAssetMetadata]); + return unlistedAssetMetadata ? [unlistedAssetMetadata] : []; + }, [searchString, searchResults, tokensToRender, unlistedAssetMetadata]); - const keyExtractor = useCallback( - (token: BridgeToken | null, index: number) => - token ? `${token.chainId}-${token.address}` : `skeleton-${index}`, - [], - ); + const keyExtractor = useCallback( + (token: BridgeToken | null, index: number) => + token ? `${token.chainId}-${token.address}` : `skeleton-${index}`, + [], + ); - const handleSearchTextChange = useCallback( - (text: string) => { - setSearchString(text); - }, - [setSearchString], - ); + const handleSearchTextChange = useCallback( + (text: string) => { + setSearchString(text); + }, + [setSearchString], + ); - const renderEmptyList = useMemo( - () => ( - - - {strings('swaps.no_tokens_result', { - searchString: debouncedSearchString, - })} - - - ), - [debouncedSearchString, styles], - ); + const renderEmptyList = useMemo( + () => ( + + + {strings('swaps.no_tokens_result', { + searchString: debouncedSearchString, + })} + + + ), + [debouncedSearchString, styles], + ); - const sheetRef = useRef(null); - const dismissModal = (): void => { - sheetRef.current?.onCloseBottomSheet(); - }; + const sheetRef = useRef(null); + const dismissModal = useCallback((): void => { + sheetRef.current?.onCloseBottomSheet(); + }, []); - const shouldRenderOverallLoading = useMemo( - () => (pending && allTokens?.length === 0) || unlistedAssetMetadataPending, - [pending, unlistedAssetMetadataPending, allTokens], - ); + const shouldRenderOverallLoading = useMemo( + () => + (pending && allTokens?.length === 0) || unlistedAssetMetadataPending, + [pending, unlistedAssetMetadataPending, allTokens], + ); - // We show the tokens with balance immediately, but we need to wait for the top tokens to load - // So we show a few skeletons for the top tokens - const tokensToRenderWithSearchAndSkeletons: (BridgeToken | null)[] = - useMemo(() => { - if (pending && tokensToRenderWithSearch?.length > 0) { - return [...tokensToRenderWithSearch, ...Array(4).fill(null)]; - } + // We show the tokens with balance immediately, but we need to wait for the top tokens to load + // So we show a few skeletons for the top tokens + const tokensToRenderWithSearchAndSkeletons: (BridgeToken | null)[] = + useMemo(() => { + if (pending && tokensToRenderWithSearch?.length > 0) { + return [...tokensToRenderWithSearch, ...Array(4).fill(null)]; + } - return tokensToRenderWithSearch; - }, [pending, tokensToRenderWithSearch]); + return tokensToRenderWithSearch; + }, [pending, tokensToRenderWithSearch]); - const placeholderTextColor = theme.colors.text.alternative; + const placeholderTextColor = theme.colors.text.alternative; - return ( - - - {title ?? strings('bridge.select_token')} - + return ( + + + {title ?? strings('bridge.select_token')} + - - {networksBar} + + {networksBar} - - + + - {/* Need this extra View to fix tokens not being reliably pressable on Android hardware, no idea why */} - - - - + {/* Need this extra View to fix tokens not being reliably pressable on Android hardware, no idea why */} + + + + + ); + }, ); -}; diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 7382019e55c..5fa427cf1d8 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -135,6 +135,35 @@ jest.mock( }), ); +jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: () => (scope: string) => { + // Return appropriate account based on the scope + if (scope.startsWith('solana:')) { + return { + id: 'solanaAccountId', + address: 'pXwSggYaFeUryz86UoCs9ugZ4VWoZ7R1U5CVhxYjL61', + name: 'Solana Account', + type: 'snap', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + lastSelected: 0, + }, + }; + } + // Default to EVM account + return { + id: 'evmAccountId', + address: '0x1234567890123456789012345678901234567890', + name: 'Account 1', + type: 'eip155:eoa', + scopes: ['eip155:1'], + metadata: { + lastSelected: 0, + }, + }; + }, +})); + jest.mock( '../../../../../selectors/featureFlagController/multichainAccounts', () => ({ diff --git a/app/components/UI/Bridge/components/TokenInputArea/index.tsx b/app/components/UI/Bridge/components/TokenInputArea/index.tsx index 9b561786876..ff159028529 100644 --- a/app/components/UI/Bridge/components/TokenInputArea/index.tsx +++ b/app/components/UI/Bridge/components/TokenInputArea/index.tsx @@ -440,3 +440,5 @@ export const TokenInputArea = forwardRef< ); }, ); + +TokenInputArea.displayName = 'TokenInputArea'; diff --git a/app/components/UI/Bridge/hooks/useNonEvmTokensWithBalance/useNonEvmTokensWithBalance.ts b/app/components/UI/Bridge/hooks/useNonEvmTokensWithBalance/useNonEvmTokensWithBalance.ts index 9dc57d38c11..e76e5b23942 100644 --- a/app/components/UI/Bridge/hooks/useNonEvmTokensWithBalance/useNonEvmTokensWithBalance.ts +++ b/app/components/UI/Bridge/hooks/useNonEvmTokensWithBalance/useNonEvmTokensWithBalance.ts @@ -1,8 +1,7 @@ import { useSelector } from 'react-redux'; import { RootState } from '../../../../../reducers'; -import { selectMultichainTokenListForAccountAnyChain } from '../../../../../selectors/multichain'; +import { selectMultichainTokenListForAccountsAnyChain } from '../../../../../selectors/multichain'; import { useNonEvmAccounts } from '../useNonEvmAccounts'; -import { useMemo } from 'react'; /** * Hook to get non-EVM tokens from all non-EVM accounts @@ -10,23 +9,9 @@ import { useMemo } from 'react'; */ export const useNonEvmTokensWithBalance = () => { const nonEvmAccounts = useNonEvmAccounts(); - const nonEvmTokens = useSelector( - useMemo( - () => (state: RootState) => { - const tokens: ReturnType< - typeof selectMultichainTokenListForAccountAnyChain - > = []; - for (const account of nonEvmAccounts) { - const tokensForAccount = selectMultichainTokenListForAccountAnyChain( - state, - account, - ); - tokens.push(...tokensForAccount); - } - return tokens; - }, - [nonEvmAccounts], - ), + + const nonEvmTokens = useSelector((state: RootState) => + selectMultichainTokenListForAccountsAnyChain(state, nonEvmAccounts), ); return nonEvmTokens; diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts index 62585146779..97e0ee14f85 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -12,8 +12,8 @@ import { selectDestToken, selectSourceAmount, } from '../../../../../core/redux/slices/bridge'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; -import { selectChainId } from '../../../../../selectors/networkController'; +import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; +import { getFormattedAddressFromInternalAccount } from '../../../../../core/Multichain/utils'; import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { toCaipAccountId, @@ -104,10 +104,19 @@ export const useRewards = ({ const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const sourceAmount = useSelector(selectSourceAmount); - const selectedAddress = useSelector( - selectSelectedInternalAccountFormattedAddress, + const getSelectedAccountByScope = useSelector( + selectSelectedInternalAccountByScope, ); - const currentChainId = useSelector(selectChainId); + + const sourceChainId = sourceToken?.chainId + ? formatChainIdToCaip(sourceToken.chainId) + : undefined; + const selectedAccount = sourceChainId + ? getSelectedAccountByScope(sourceChainId) + : undefined; + const selectedAddress = selectedAccount + ? getFormattedAddressFromInternalAccount(selectedAccount) + : undefined; const estimatePoints = useCallback(async () => { // Skip if no active quote or missing required data @@ -117,7 +126,7 @@ export const useRewards = ({ !destToken || !sourceAmount || !selectedAddress || - !currentChainId + !sourceChainId ) { setEstimatedPoints(null); return; @@ -141,7 +150,7 @@ export const useRewards = ({ // Format account to CAIP-10 const caipAccount = formatAccountToCaipAccountId( selectedAddress, - currentChainId, + sourceChainId, ); if (!caipAccount) { @@ -232,7 +241,7 @@ export const useRewards = ({ destToken, sourceAmount, selectedAddress, - currentChainId, + sourceChainId, ]); // Estimate points when dependencies change diff --git a/app/components/UI/Bridge/hooks/useTokenSearch/index.ts b/app/components/UI/Bridge/hooks/useTokenSearch/index.ts index 37ebc4f61e7..3981a09840f 100644 --- a/app/components/UI/Bridge/hooks/useTokenSearch/index.ts +++ b/app/components/UI/Bridge/hooks/useTokenSearch/index.ts @@ -1,9 +1,12 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import Fuse from 'fuse.js'; +import { throttle } from 'lodash'; import { BridgeToken } from '../../types'; import { useDebouncedValue } from '../../../../hooks/useDebouncedValue'; +const THROTTLE_MS = 1250; // Throttle token updates to max once per second const MAX_TOKENS_RESULTS = 20; + interface UseTokenSearchProps { tokens: BridgeToken[]; } @@ -20,26 +23,38 @@ export function useTokenSearch({ }: UseTokenSearchProps): UseTokenSearchResult { const [searchString, setSearchString] = useState(''); const debouncedSearchString = useDebouncedValue(searchString); + // We start with empty array to avoid initial expensive initialization of Fuse instance, + // because it's immediately replaced with new instance during intial 1s anyway (useAsyncResult in useTopTokens triggest it) + const [throttledTokens, setThrottledTokens] = useState([]); - const tokenFuse = useMemo( - () => - new Fuse(tokens || [], { - shouldSort: true, - threshold: 0.45, - location: 0, - distance: 100, - maxPatternLength: 32, - minMatchCharLength: 1, - keys: ['symbol', 'name', 'address'], - }), - [tokens], - ); + // Create stable throttled setter that persists across renders + const throttledSetTokens = useRef( + throttle((newTokens: BridgeToken[]) => { + setThrottledTokens(newTokens); + }, THROTTLE_MS), + ).current; + + useEffect(() => { + throttledSetTokens(tokens); + }, [tokens, throttledSetTokens]); + + const tokenFuse = useMemo(() => { + const result = new Fuse(throttledTokens || [], { + shouldSort: true, + threshold: 0.45, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: ['symbol', 'name', 'address'], + }); + return result; + }, [throttledTokens]); const tokenSearchResults = useMemo( () => tokenFuse - .search(debouncedSearchString) - .slice(0, MAX_TOKENS_RESULTS) + .search(debouncedSearchString, { limit: MAX_TOKENS_RESULTS }) .sort((a, b) => { // Sort results by balance fiat in descending order const balanceA = a.tokenFiatAmount ?? 0; diff --git a/app/components/UI/Bridge/hooks/useTokens.ts b/app/components/UI/Bridge/hooks/useTokens.ts index 2f618452faa..9ff07837289 100644 --- a/app/components/UI/Bridge/hooks/useTokens.ts +++ b/app/components/UI/Bridge/hooks/useTokens.ts @@ -1,3 +1,5 @@ +import { useCallback, useMemo } from 'react'; + import { useTokensWithBalance } from './useTokensWithBalance'; import { Hex, CaipChainId } from '@metamask/utils'; import { useTopTokens } from './useTopTokens'; @@ -15,7 +17,6 @@ interface UseTokensProps { balanceChainIds?: (Hex | CaipChainId)[]; tokensToExclude?: { address: string; chainId: Hex | CaipChainId }[]; } - /** * Hook to get tokens for the bridge * @param {Object} params - The parameters object @@ -41,78 +42,94 @@ export function useTokens({ chainId: topTokensChainId, }); - const getTokenKey = (token: { - address: string; - chainId: Hex | CaipChainId; - }) => { - // Use the shared utility for non-EVM normalization to ensure consistent deduplication - let normalizedAddress = isNonEvmChainId(token.chainId) - ? formatAddressToAssetId(token.address, token.chainId) - : token.address.toLowerCase(); + const getTokenKey = useCallback( + (token: { address: string; chainId: Hex | CaipChainId }) => { + // Use the shared utility for non-EVM normalization to ensure consistent deduplication + let normalizedAddress = isNonEvmChainId(token.chainId) + ? formatAddressToAssetId(token.address, token.chainId) + : token.address.toLowerCase(); - if (!normalizedAddress) { - throw new Error( - `Invalid token address: ${token.address} for chain ID: ${token.chainId}`, - ); - } + if (!normalizedAddress) { + throw new Error( + `Invalid token address: ${token.address} for chain ID: ${token.chainId}`, + ); + } - // Normalize the native token address for Polygon - // Prevents duplicate tokens with different addresses from - // rendering in the UI - if (normalizedAddress === POLYGON_NATIVE_TOKEN) { - normalizedAddress = zeroAddress(); - } + // Normalize the native token address for Polygon + // Prevents duplicate tokens with different addresses from + // rendering in the UI + if (normalizedAddress === POLYGON_NATIVE_TOKEN) { + normalizedAddress = zeroAddress(); + } - return `${normalizedAddress}-${token.chainId}`; - }; + return `${normalizedAddress}-${token.chainId}`; + }, + [], + ); // Create Sets for O(1) lookups - const tokensWithBalanceSet = new Set( - tokensWithBalance.map((token) => getTokenKey(token)), + const tokensWithBalanceSet = useMemo( + () => new Set(tokensWithBalance.map((token) => getTokenKey(token))), + [tokensWithBalance, getTokenKey], ); - const excludedTokensSet = new Set( - tokensToExclude?.map((token) => getTokenKey(token)) ?? [], + const excludedTokensSet = useMemo( + () => new Set(tokensToExclude?.map((token) => getTokenKey(token)) ?? []), + [tokensToExclude, getTokenKey], ); // Combine and filter tokens in a single pass - const tokensWithoutBalance = (topTokens ?? []) - .concat(remainingTokens ?? []) - .filter((token) => { - if (!isTradableToken(token)) { - return false; - } + const tokensWithoutBalance = useMemo( + () => + (topTokens ?? []).concat(remainingTokens ?? []).filter((token) => { + if (!isTradableToken(token)) { + return false; + } - const tokenKey = getTokenKey(token); - return !tokensWithBalanceSet.has(tokenKey); - }); + const tokenKey = getTokenKey(token); + return !tokensWithBalanceSet.has(tokenKey); + }), + [topTokens, remainingTokens, getTokenKey, tokensWithBalanceSet], + ); // Combine tokens with balance and filtered tokens and filter out excluded tokens - const allTokens = tokensWithBalance - .concat(tokensWithoutBalance) - .filter((token) => { - if (!isTradableToken(token)) { - return false; - } + const allTokens = useMemo( + () => + tokensWithBalance.concat(tokensWithoutBalance).filter((token) => { + if (!isTradableToken(token)) { + return false; + } - const tokenKey = getTokenKey(token); - return !excludedTokensSet.has(tokenKey); - }); - - const tokensToRender = tokensWithBalance - .concat( - topTokens?.filter((token) => { const tokenKey = getTokenKey(token); - return !tokensWithBalanceSet.has(tokenKey); - }) ?? [], - ) - .filter((token) => { - if (!isTradableToken(token)) { - return false; - } + return !excludedTokensSet.has(tokenKey); + }), + [tokensWithBalance, tokensWithoutBalance, getTokenKey, excludedTokensSet], + ); - const tokenKey = getTokenKey(token); - return !excludedTokensSet.has(tokenKey); - }); + const tokensToRender = useMemo( + () => + tokensWithBalance + .concat( + topTokens?.filter((token) => { + const tokenKey = getTokenKey(token); + return !tokensWithBalanceSet.has(tokenKey); + }) ?? [], + ) + .filter((token) => { + if (!isTradableToken(token)) { + return false; + } + + const tokenKey = getTokenKey(token); + return !excludedTokensSet.has(tokenKey); + }), + [ + tokensWithBalance, + topTokens, + getTokenKey, + tokensWithBalanceSet, + excludedTokensSet, + ], + ); return { allTokens, tokensToRender, pending }; } diff --git a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts index 033df149959..07300bd482f 100644 --- a/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts +++ b/app/components/UI/Bridge/hooks/useTokensWithBalance/index.ts @@ -26,6 +26,10 @@ import { selectSelectedAccountGroupInternalAccounts } from '../../../../../selec import { EthScope } from '@metamask/keyring-api'; import { useNonEvmTokensWithBalance } from '../useNonEvmTokensWithBalance'; import { getTokenIconUrl } from '../../utils'; +import { + formatAddressToAssetId, + isNonEvmChainId, +} from '@metamask/bridge-controller'; interface CalculateFiatBalancesParams { assets: TokenI[]; @@ -204,6 +208,7 @@ export const useTokensWithBalance: ({ .map((token, i) => { const evmBalance = evmBalances?.[i]?.balance; const nonEvmBalance = renderNumber(token.balance ?? '0'); + const chainId = token.chainId as Hex | CaipChainId; const evmBalanceFiat = evmBalances?.[i]?.balanceFiat; const nonEvmBalanceFiat = renderFiat( @@ -219,11 +224,11 @@ export const useTokensWithBalance: ({ name: token.name, decimals: token.decimals, symbol: token.isETH ? 'ETH' : token.symbol, // TODO: not sure why symbol is ETHEREUM, will also break the token icon for ETH - chainId: token.chainId as Hex | CaipChainId, + chainId, image: getTokenIconUrl( - token.address, - token.chainId as Hex | CaipChainId, + formatAddressToAssetId(token.address, chainId), + isNonEvmChainId(chainId), ) || token.image, tokenFiatAmount: evmTokenFiatAmount ?? nonEvmTokenFiatAmount, balance: evmBalance ?? nonEvmBalance, diff --git a/app/components/UI/Bridge/hooks/useTopTokens/index.ts b/app/components/UI/Bridge/hooks/useTopTokens/index.ts index 647208f7d54..fdd6121b33d 100644 --- a/app/components/UI/Bridge/hooks/useTopTokens/index.ts +++ b/app/components/UI/Bridge/hooks/useTopTokens/index.ts @@ -57,14 +57,14 @@ const formatCachedTokenListControllerTokens = ( ): Record => { const bridgeTokenObj: Record = {}; - Object.entries(cachedTokens).forEach(([address, token]) => { - const caipChainId = formatChainIdToCaip(chainId); - const hexChainId = formatChainIdToHex(chainId); + const caipChainId = formatChainIdToCaip(chainId); + const hexChainId = formatChainIdToHex(chainId); + const isNonEnvChain = isNonEvmChainId(caipChainId); + Object.entries(cachedTokens).forEach(([address, token]) => { // Convert non-EVM addresses to CAIP format for consistent deduplication - const tokenAddress = isNonEvmChainId(caipChainId) - ? formatAddressToAssetId(token.address, caipChainId) - : token.address; + const assetId = formatAddressToAssetId(token.address, caipChainId); + const tokenAddress = isNonEnvChain ? assetId : token.address; if (!tokenAddress) { throw new Error( @@ -76,9 +76,9 @@ const formatCachedTokenListControllerTokens = ( address: tokenAddress, symbol: token.symbol, name: token.name, - image: getTokenIconUrl(tokenAddress, chainId) || token.iconUrl || '', + image: getTokenIconUrl(assetId, isNonEnvChain) || token.iconUrl || '', decimals: token.decimals, - chainId: isNonEvmChainId(caipChainId) ? caipChainId : hexChainId, + chainId: isNonEnvChain ? caipChainId : hexChainId, accountType: getAccountType(caipChainId), }; }); @@ -232,11 +232,10 @@ export const useTopTokens = ({ []; // Helper function to add a token if it's not already added and we haven't reached the limit - const addTokenIfNotExists = (token: BridgeToken) => { - const normalizedAddress = isNonEvmChainId(token.chainId) - ? token.address // Solana addresses are case-sensitive, TODO but are Bitcoin addresses case-sensitive? - : token.address.toLowerCase(); // EVM addresses are case-insensitive - + const addTokenIfNotExists = ( + token: BridgeToken, + normalizedAddress: string, + ) => { if (!addedAddresses.has(normalizedAddress)) { addedAddresses.add(normalizedAddress); if (result.length < MAX_TOP_TOKENS) { @@ -257,7 +256,10 @@ export const useTopTokens = ({ bridgeTokens[toChecksumHexAddress(topAssetAddr)]; if (candidateBridgeToken) { - addTokenIfNotExists(candidateBridgeToken); + const normalizedAddress = isNonEvmChainId(candidateBridgeToken.chainId) + ? candidateBridgeToken.address // Solana addresses are case-sensitive, TODO but are Bitcoin addresses case-sensitive? + : candidateBridgeToken.address.toLowerCase(); // EVM addresses are case-insensitive + addTokenIfNotExists(candidateBridgeToken, normalizedAddress); } } @@ -267,12 +269,7 @@ export const useTopTokens = ({ ? token.address // Solana addresses are case-sensitive, TODO but are Bitcoin addresses case-sensitive? : token.address.toLowerCase(); // EVM addresses are case-insensitive - // Skip if already added to top tokens - if (addedAddresses.has(normalizedAddress)) { - continue; - } - - addTokenIfNotExists(token); + addTokenIfNotExists(token, normalizedAddress); } return { diff --git a/app/components/UI/Bridge/utils/index.test.ts b/app/components/UI/Bridge/utils/index.test.ts index c4fe1f92d0b..86b5b81a04e 100644 --- a/app/components/UI/Bridge/utils/index.test.ts +++ b/app/components/UI/Bridge/utils/index.test.ts @@ -36,23 +36,11 @@ jest.mock('../../../../core/Engine', () => ({ }, })); -jest.mock('@metamask/bridge-controller', () => ({ - ...jest.requireActual('@metamask/bridge-controller'), - formatAddressToAssetId: jest.fn(), - isNonEvmChainId: jest.fn(), -})); - const mockWipeBridgeStatus = Engine.context.BridgeStatusController .wipeBridgeStatus as jest.MockedFunction< typeof Engine.context.BridgeStatusController.wipeBridgeStatus >; -const mockFormatAddressToAssetId = - formatAddressToAssetId as jest.MockedFunction; -const mockIsNonEvmChainId = isNonEvmChainId as jest.MockedFunction< - typeof isNonEvmChainId ->; - describe('Bridge Utils', () => { beforeEach(() => { jest.clearAllMocks(); @@ -71,18 +59,18 @@ describe('Bridge Utils', () => { LINEA_CHAIN_ID, ]; - it('return true when bridge is active and chain ID is allowed', () => { + it('should return true when bridge is active and chain ID is allowed', () => { supportedChainIds.forEach((chainId) => { expect(isBridgeAllowed(chainId)).toBe(true); }); }); - it('return false when bridge is active but chain ID is not allowed', () => { + it('should return false when bridge is active but chain ID is not allowed', () => { const unsupportedChainId = '0x1234' as Hex; expect(isBridgeAllowed(unsupportedChainId)).toBe(false); }); - it('return false when bridge is inactive', () => { + it('should return false when bridge is inactive', () => { Object.defineProperty(AppConstants.BRIDGE, 'ACTIVE', { get: () => false, }); @@ -92,7 +80,7 @@ describe('Bridge Utils', () => { }); }); - it('handle invalid chain ID formats', () => { + it('should handle invalid chain ID formats', () => { const invalidChainIds = ['0x123' as Hex, '0x' as Hex]; invalidChainIds.forEach((chainId) => { @@ -100,7 +88,7 @@ describe('Bridge Utils', () => { }); }); - it('handle edge cases', () => { + it('should handle edge cases', () => { // Test with malformed chain ID expect( isBridgeAllowed( @@ -115,9 +103,7 @@ describe('Bridge Utils', () => { const testAddressLowercase = testAddress.toLowerCase(); const evmChainId = ETH_CHAIN_ID; - it('calls wipeBridgeStatus twice for EVM chains with original and lowercase address', () => { - mockIsNonEvmChainId.mockReturnValue(false); - + it('should call wipeBridgeStatus twice for EVM chains (original and lowercase address)', () => { wipeBridgeStatus(testAddress, evmChainId); expect(mockWipeBridgeStatus).toHaveBeenCalledTimes(2); @@ -131,9 +117,7 @@ describe('Bridge Utils', () => { }); }); - it('calls wipeBridgeStatus once for Solana chains with original address only', () => { - mockIsNonEvmChainId.mockReturnValue(true); - + it('should call wipeBridgeStatus only once for Solana chains (original address only)', () => { wipeBridgeStatus(testAddress, SolScope.Mainnet); expect(mockWipeBridgeStatus).toHaveBeenCalledTimes(1); @@ -145,110 +129,111 @@ describe('Bridge Utils', () => { }); describe('getTokenIconUrl', () => { - beforeEach(() => { - mockIsNonEvmChainId.mockReturnValue(false); - }); - - it('returns token icon URL for native token on Ethereum', () => { + it('should return token icon URL for native token on Ethereum', () => { + // Arrange const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; - mockFormatAddressToAssetId.mockReturnValue('eip155:1/slip44:60'); + const nativeTokenAssetId = formatAddressToAssetId( + nativeTokenAddress, + ETH_CHAIN_ID, + ); - const result = getTokenIconUrl(nativeTokenAddress, ETH_CHAIN_ID); + // Act + const result = getTokenIconUrl( + nativeTokenAssetId, + isNonEvmChainId(ETH_CHAIN_ID), + ); + // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', ); }); - it('returns token icon URL for ERC20 token on Ethereum', () => { + it('should return token icon URL for ERC20 token on Ethereum', () => { + // Arrange const usdcAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; - mockFormatAddressToAssetId.mockReturnValue( - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - ); + const usdcAssetId = formatAddressToAssetId(usdcAddress, ETH_CHAIN_ID); - const result = getTokenIconUrl(usdcAddress, ETH_CHAIN_ID); + // Act + const result = getTokenIconUrl( + usdcAssetId, + isNonEvmChainId(ETH_CHAIN_ID), + ); + // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', ); }); - it('returns token icon URL for Solana native token', () => { + it('should return token icon URL for Solana native token', () => { + // Arrange const solNativeAddress = '0x0000000000000000000000000000000000000000'; - mockIsNonEvmChainId.mockReturnValue(true); - mockFormatAddressToAssetId.mockReturnValue( - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + const solNativeAssetId = formatAddressToAssetId( + solNativeAddress, + SolScope.Mainnet, + ); + // Act + const result = getTokenIconUrl( + solNativeAssetId, + isNonEvmChainId(SolScope.Mainnet), ); - const result = getTokenIconUrl(solNativeAddress, SolScope.Mainnet); - + // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44/501.png', ); }); - it('returns token icon URL for Solana SPL token', () => { + it('should return token icon URL for Solana SPL token', () => { + // Arrange const usdcSolanaAddress = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - mockIsNonEvmChainId.mockReturnValue(true); - mockFormatAddressToAssetId.mockReturnValue( - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + const usdcSolanaAssetId = formatAddressToAssetId( + usdcSolanaAddress, + SolScope.Mainnet, ); - const result = getTokenIconUrl(usdcSolanaAddress, SolScope.Mainnet); + // Act + const result = getTokenIconUrl( + usdcSolanaAssetId, + isNonEvmChainId(SolScope.Mainnet), + ); + // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/solana/5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v.png', ); }); - it('returns undefined when formatAddressToAssetId returns null', () => { - const address = '0x1234567890123456789012345678901234567890'; - // @ts-expect-error Testing null return value - mockFormatAddressToAssetId.mockReturnValue(null); - - const result = getTokenIconUrl(address, ETH_CHAIN_ID); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when formatAddressToAssetId returns undefined', () => { - const address = '0x1234567890123456789012345678901234567890'; - mockFormatAddressToAssetId.mockReturnValue(undefined); - - const result = getTokenIconUrl(address, ETH_CHAIN_ID); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when formatAddressToAssetId throws error for unsupported chain', () => { - const address = '0x1234567890123456789012345678901234567890'; - const unsupportedChainId = '0x9999' as Hex; - mockFormatAddressToAssetId.mockImplementation(() => { - throw new Error('Unsupported chain'); - }); - - const result = getTokenIconUrl(address, unsupportedChainId); - - expect(result).toBeUndefined(); - }); - - it('returns undefined when formatAddressToAssetId throws error for invalid address format', () => { - const invalidAddress = 'invalid-address-format'; - mockFormatAddressToAssetId.mockImplementation(() => { - throw new Error('Invalid address format'); - }); + it('should return undefined for invalid address', () => { + // Arrange + const invalidAddress = 'invalid'; + const invalidAssetId = formatAddressToAssetId( + invalidAddress, + ETH_CHAIN_ID, + ); - const result = getTokenIconUrl(invalidAddress, ETH_CHAIN_ID); + // Act + const result = getTokenIconUrl( + invalidAssetId, + isNonEvmChainId(ETH_CHAIN_ID), + ); + // Assert expect(result).toBeUndefined(); }); - it('returns token icon URL for empty address when formatAddressToAssetId succeeds', () => { + it('should return native token icon URL for empty address', () => { + // Arrange const emptyAddress = ''; - mockFormatAddressToAssetId.mockReturnValue('eip155:1/slip44:60'); - - const result = getTokenIconUrl(emptyAddress, ETH_CHAIN_ID); + const emptyAssetId = formatAddressToAssetId(emptyAddress, ETH_CHAIN_ID); + // Act + const result = getTokenIconUrl( + emptyAssetId, + isNonEvmChainId(ETH_CHAIN_ID), + ); + // Assert expect(result).toBe( 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', ); diff --git a/app/components/UI/Bridge/utils/index.ts b/app/components/UI/Bridge/utils/index.ts index 30c15699558..ef49350f21a 100644 --- a/app/components/UI/Bridge/utils/index.ts +++ b/app/components/UI/Bridge/utils/index.ts @@ -1,6 +1,6 @@ import { CaipChainId, SolScope } from '@metamask/keyring-api'; import AppConstants from '../../../../core/AppConstants'; -import { Hex } from '@metamask/utils'; +import { CaipAssetType, Hex } from '@metamask/utils'; import { ARBITRUM_CHAIN_ID, AVALANCHE_CHAIN_ID, @@ -14,10 +14,7 @@ import { SEI_CHAIN_ID, } from '@metamask/swaps-controller/dist/constants'; import Engine from '../../../../core/Engine'; -import { - formatAddressToAssetId, - isNonEvmChainId, -} from '@metamask/bridge-controller'; +import { isNonEvmChainId } from '@metamask/bridge-controller'; import { CHAIN_IDS } from '@metamask/transaction-controller'; const ALLOWED_CHAIN_IDS: (Hex | CaipChainId)[] = [ @@ -62,25 +59,14 @@ export const wipeBridgeStatus = ( }; export const getTokenIconUrl = ( - address: string, - chainId: Hex | CaipChainId, + assetId: CaipAssetType | undefined, + isNonEvmChain: boolean, ) => { - const isEvmChain = !isNonEvmChainId(chainId); - const formattedAddress = isEvmChain ? address.toLowerCase() : address; - - try { - const assetId = formatAddressToAssetId(formattedAddress, chainId); - if (!assetId) { - return undefined; - } - return `https://static.cx.metamask.io/api/v2/tokenIcons/assets/${assetId - .split(':') - .join('/')}.png`; - } catch (error) { - // formatAddressToAssetId may throw for unsupported chains. This is expected behavior, - // so we gracefully handle it by returning undefined rather than propagating the error. - // This prevents the app from crashing when attempting to fetch icons for tokens on - // chains that aren't yet supported by the tokenIcons API. + if (!assetId) { return undefined; } + const formattedAddress = isNonEvmChain ? assetId : assetId.toLowerCase(); + return `https://static.cx.metamask.io/api/v2/tokenIcons/assets/${formattedAddress + .split(':') + .join('/')}.png`; }; diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index e1477007690..66568edb370 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -43,7 +43,9 @@ import { useSelector } from 'react-redux'; import React from 'react'; import CardHome from './CardHome'; import { cardDefaultNavigationOptions } from '../../routes'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import renderWithProvider, { + renderScreen, +} from '../../../../../util/test/renderWithProvider'; import { withCardSDK } from '../../sdk'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import Routes from '../../../../../constants/navigation/Routes'; @@ -68,8 +70,9 @@ import { useIsSwapEnabledForPriorityToken } from '../../hooks/useIsSwapEnabledFo const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); const mockSetNavigationOptions = jest.fn(); +const mockNavigationDispatch = jest.fn(); -import { useFocusEffect } from '@react-navigation/native'; +import { useFocusEffect, StackActions } from '@react-navigation/native'; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -80,7 +83,14 @@ jest.mock('@react-navigation/native', () => { navigate: mockNavigate, goBack: mockGoBack, setOptions: mockSetNavigationOptions, + dispatch: mockNavigationDispatch, }), + StackActions: { + replace: jest.fn((routeName) => ({ + type: 'REPLACE', + routeName, + })), + }, }; }); @@ -263,6 +273,37 @@ jest.mock('@metamask/bridge-controller', () => ({ isSolanaChainId: jest.fn(), })); +// Mock authentication error utility +const mockIsAuthenticationError = jest.fn(); +jest.mock('../../util/isAuthenticationError', () => ({ + isAuthenticationError: (...args: unknown[]) => + mockIsAuthenticationError(...args), +})); + +// Mock card token vault +const mockRemoveCardBaanxToken = jest.fn(); +jest.mock('../../util/cardTokenVault', () => ({ + removeCardBaanxToken: () => mockRemoveCardBaanxToken(), +})); + +// Mock Redux card actions +const mockResetAuthenticatedData = jest.fn(() => ({ + type: 'card/resetAuthenticatedData', +})); +const mockClearAllCache = jest.fn(() => ({ + type: 'card/clearAllCache', +})); +jest.mock('../../../../../core/redux/slices/card', () => { + const actualModule = jest.requireActual( + '../../../../../core/redux/slices/card', + ); + return { + ...actualModule, + resetAuthenticatedData: () => mockResetAuthenticatedData(), + clearAllCache: () => mockClearAllCache(), + }; +}); + // Mock Logger jest.mock('../../../../../util/Logger', () => ({ error: jest.fn(), @@ -503,6 +544,15 @@ describe('CardHome Component', () => { mockLogoutFromProvider.mockClear(); mockSetIsAuthenticated.mockClear(); + // Clear authentication error handling mocks + mockIsAuthenticationError.mockClear(); + mockIsAuthenticationError.mockReturnValue(false); // Default to no auth error + mockRemoveCardBaanxToken.mockClear(); + mockRemoveCardBaanxToken.mockResolvedValue(undefined); + mockResetAuthenticatedData.mockClear(); + mockClearAllCache.mockClear(); + mockNavigationDispatch.mockClear(); + // Setup Engine controller mocks mockFetchPriorityToken.mockImplementation(async () => mockPriorityToken); mockDispatch.mockClear(); @@ -2330,4 +2380,354 @@ describe('CardHome Component', () => { expect(screen.getByText('0/0 USDC')).toBeOnTheScreen(); }); }); + + describe('Authentication Error Handling', () => { + it('clears auth state and navigates to welcome when authentication error occurs', async () => { + // Given: authenticated user with authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + setupLoadCardDataMock({ + error: 'Authentication failed', + isAuthenticated: true, + }); + + // When: component renders with authentication error + render(); + + // Then: should clear token, reset auth state, and navigate to welcome + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'card/resetAuthenticatedData' }), + ); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'card/clearAllCache' }), + ); + }); + + await waitFor(() => { + expect(StackActions.replace).toHaveBeenCalledWith(Routes.CARD.WELCOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'REPLACE', + routeName: Routes.CARD.WELCOME, + }), + ); + }); + }); + + it('does nothing when no error exists', async () => { + // Given: authenticated user without error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(false); + setupLoadCardDataMock({ + error: null, + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should not trigger authentication error handling + await new Promise((r) => setTimeout(r, 100)); + expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); + expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); + expect(mockClearAllCache).not.toHaveBeenCalled(); + expect(mockNavigationDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'REPLACE' }), + ); + }); + + it('does nothing when user is not authenticated', async () => { + // Given: non-authenticated user with error + setupMockSelectors({ isAuthenticated: false }); + mockIsAuthenticationError.mockReturnValue(false); + setupLoadCardDataMock({ + error: 'Some error', + isAuthenticated: false, + }); + + // When: component renders + render(); + + // Then: should not trigger authentication error handling + await new Promise((r) => setTimeout(r, 100)); + expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); + expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); + expect(mockClearAllCache).not.toHaveBeenCalled(); + }); + + it('does nothing when error is not an authentication error', async () => { + // Given: authenticated user with non-authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(false); + setupLoadCardDataMock({ + error: 'Network error', + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should not trigger authentication error handling + await new Promise((r) => setTimeout(r, 100)); + expect(mockRemoveCardBaanxToken).not.toHaveBeenCalled(); + expect(mockResetAuthenticatedData).not.toHaveBeenCalled(); + expect(mockClearAllCache).not.toHaveBeenCalled(); + expect(mockNavigationDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'REPLACE' }), + ); + }); + + it('still navigates when token removal fails', async () => { + // Given: authenticated user with authentication error and token removal fails + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + mockRemoveCardBaanxToken.mockRejectedValue( + new Error('Failed to remove token'), + ); + setupLoadCardDataMock({ + error: 'Token expired', + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should still navigate even if token removal fails + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(StackActions.replace).toHaveBeenCalledWith(Routes.CARD.WELCOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'REPLACE', + routeName: Routes.CARD.WELCOME, + }), + ); + }); + }); + + it('logs error when token removal fails', async () => { + // Given: authenticated user with authentication error and token removal fails + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + mockRemoveCardBaanxToken.mockRejectedValue( + new Error('Failed to remove token'), + ); + setupLoadCardDataMock({ + error: 'Invalid credentials', + isAuthenticated: true, + }); + + const Logger = jest.requireMock('../../../../../util/Logger'); + + // When: component renders + render(); + + // Then: should log the error + await waitFor(() => { + expect(Logger.log).toHaveBeenCalledWith( + 'CardHome: Failed to handle authentication error', + expect.any(Error), + ); + }); + }); + + it('dispatches Redux actions after successful token removal', async () => { + // Given: authenticated user with authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + mockRemoveCardBaanxToken.mockResolvedValue(undefined); + setupLoadCardDataMock({ + error: 'Unauthorized', + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should dispatch Redux actions after token removal + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'card/resetAuthenticatedData' }), + ); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'card/clearAllCache' }), + ); + }); + }); + + it('calls isAuthenticationError with the correct error', async () => { + // Given: authenticated user with error + setupMockSelectors({ isAuthenticated: true }); + const testError = 'Test authentication error'; + mockIsAuthenticationError.mockReturnValue(true); + setupLoadCardDataMock({ + error: testError, + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: should call isAuthenticationError with the error + await waitFor(() => { + expect(mockIsAuthenticationError).toHaveBeenCalledWith(testError); + }); + }); + + it('runs authentication cleanup once even when error persists across renders', async () => { + // Given: authenticated user with persistent authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + const WrappedCardHome = withCardSDK(CardHome); + + setupLoadCardDataMock({ + error: 'First auth error', + isAuthenticated: true, + warning: null, + priorityToken: mockPriorityToken, + }); + + setupLoadCardDataMock({ + error: 'First auth error', + isAuthenticated: true, + warning: null, + priorityToken: mockPriorityToken, + }); + + // When: component renders twice with the same authentication error + const { rerender } = renderWithProvider(, { + state: { + engine: { + backgroundState, + }, + }, + }); + + // Then: cleanup runs once on initial render + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); + }); + + // When: component re-renders with same error + rerender(); + + // Then: cleanup does not run again for unchanged error + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); + }); + }); + + it('does not dispatch Redux actions if token removal throws and component unmounts', async () => { + // Given: authenticated user with authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + let resolveTokenRemoval!: () => void; + const tokenRemovalPromise = new Promise((resolve) => { + resolveTokenRemoval = resolve; + }); + mockRemoveCardBaanxToken.mockReturnValue(tokenRemovalPromise); + setupLoadCardDataMock({ + error: 'Token expired', + isAuthenticated: true, + }); + + // When: component renders and unmounts before token removal completes + const { unmount } = render(); + + // Wait for token removal to be called + await waitFor(() => { + expect(mockRemoveCardBaanxToken).toHaveBeenCalled(); + }); + + // Unmount before resolving + unmount(); + + // Resolve the promise after unmount + resolveTokenRemoval(); + + // Wait a bit to ensure no actions are dispatched + await new Promise((r) => setTimeout(r, 100)); + + // Then: should not dispatch actions after unmount + // Note: This is a safety check - the component guards against this with isMounted + // The exact behavior depends on timing, so we just verify no errors occur + expect(mockRemoveCardBaanxToken).toHaveBeenCalledTimes(1); + }); + + it('logs info message when authentication error is detected', async () => { + // Given: authenticated user with authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + setupLoadCardDataMock({ + error: 'Authentication failed', + isAuthenticated: true, + }); + + const Logger = jest.requireMock('../../../../../util/Logger'); + + // When: component renders + render(); + + // Then: should log info message about clearing auth state + await waitFor(() => { + expect(Logger.log).toHaveBeenCalledWith( + 'CardHome: Authentication error detected, clearing auth state and redirecting', + ); + }); + }); + + it('executes cleanup operations in correct order', async () => { + // Given: authenticated user with authentication error + setupMockSelectors({ isAuthenticated: true }); + mockIsAuthenticationError.mockReturnValue(true); + const callOrder: string[] = []; + + mockRemoveCardBaanxToken.mockImplementation(async () => { + callOrder.push('removeToken'); + }); + + mockDispatch.mockImplementation((action) => { + if (action.type === 'card/resetAuthenticatedData') { + callOrder.push('resetAuth'); + } else if (action.type === 'card/clearAllCache') { + callOrder.push('clearCache'); + } + return action; + }); + + mockNavigationDispatch.mockImplementation(() => { + callOrder.push('navigate'); + }); + + setupLoadCardDataMock({ + error: 'Token expired', + isAuthenticated: true, + }); + + // When: component renders + render(); + + // Then: operations should execute in correct order + await waitFor(() => { + expect(callOrder).toEqual([ + 'removeToken', + 'resetAuth', + 'clearCache', + 'navigate', + ]); + }); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 1ba9b8d633e..3dc62dd2af8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -7,6 +7,7 @@ import React, { useState, } from 'react'; import { + ActivityIndicator, Alert, RefreshControl, ScrollView, @@ -22,7 +23,7 @@ import Icon, { import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import { useNavigation } from '@react-navigation/native'; +import { StackActions, useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; import SensitiveText, { SensitiveTextLength, @@ -58,11 +59,8 @@ import { import { useCardSDK } from '../../sdk'; import Routes from '../../../../../constants/navigation/Routes'; import { - setIsAuthenticatedCard, - setAuthenticatedPriorityToken, - setAuthenticatedPriorityTokenLastFetched, - setUserCardLocation, clearAllCache, + resetAuthenticatedData, } from '../../../../../core/redux/slices/card'; import { useCardProvision } from '../../hooks/useCardProvision'; import CardWarningBox from '../../components/CardWarningBox/CardWarningBox'; @@ -97,8 +95,13 @@ const CardHome = () => { const { PreferencesController } = Engine.context; const [retries, setRetries] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); + const [isHandlingAuthError, setIsHandlingAuthError] = useState(false); const { toastRef } = useContext(ToastContext); const { logoutFromProvider, isLoading: isSDKLoading } = useCardSDK(); + const hasTrackedCardHomeView = useRef(false); + const hasLoadedCardHomeView = useRef(false); + const hasHandledAuthErrorRef = useRef(false); + const isComponentUnmountedRef = useRef(false); const [ isCloseSpendingLimitWarningShown, setIsCloseSpendingLimitWarningShown, @@ -190,9 +193,6 @@ const CardHome = () => { } }, [fetchAllData]); - // Track event only once after priorityToken and balances are loaded - const hasTrackedCardHomeView = useRef(false); - useEffect(() => { // Early return if already tracked to prevent any possibility of duplicate tracking if (hasTrackedCardHomeView.current) { @@ -497,36 +497,75 @@ const CardHome = () => { styles, ]); + useEffect( + () => () => { + isComponentUnmountedRef.current = true; + }, + [], + ); + // Handle authentication errors (expired token, invalid credentials, etc.) useEffect(() => { const handleAuthenticationError = async () => { - if (!cardError) { + const isAuthError = + Boolean(cardError) && + isAuthenticated && + isAuthenticationError(cardError); + + if (!isAuthError) { + hasHandledAuthErrorRef.current = false; return; } - // Check if the error is authentication-related - if (isAuthenticated && isAuthenticationError(cardError)) { - Logger.log( - 'CardHome: Authentication error detected, clearing auth state and redirecting', - ); + if (hasHandledAuthErrorRef.current) { + return; + } + + hasHandledAuthErrorRef.current = true; + setIsHandlingAuthError(true); + + Logger.log( + 'CardHome: Authentication error detected, clearing auth state and redirecting', + ); - // Clear authentication state + try { await removeCardBaanxToken(); - dispatch(setIsAuthenticatedCard(false)); - dispatch(setAuthenticatedPriorityToken(null)); - dispatch(setAuthenticatedPriorityTokenLastFetched(null)); - dispatch(setUserCardLocation(null)); - // Clear all cached data + if (isComponentUnmountedRef.current) { + return; + } + + dispatch(resetAuthenticatedData()); dispatch(clearAllCache()); - // Redirect to welcome screen for re-authentication - navigation.navigate(Routes.CARD.WELCOME); + navigation.dispatch(StackActions.replace(Routes.CARD.WELCOME)); + } catch (error) { + Logger.log('CardHome: Failed to handle authentication error', error); + + if (!isComponentUnmountedRef.current) { + navigation.dispatch(StackActions.replace(Routes.CARD.WELCOME)); + } + } finally { + if (!isComponentUnmountedRef.current) { + setIsHandlingAuthError(false); + } } }; handleAuthenticationError(); - }, [cardError, isAuthenticated, dispatch, navigation]); + }, [cardError, dispatch, isAuthenticated, navigation]); + + // Load Card Data once CardHome opens + useEffect(() => { + const loadCardData = async () => { + await fetchAllData(); + hasLoadedCardHomeView.current = true; + }; + + if (!hasLoadedCardHomeView.current && isAuthenticated) { + loadCardData(); + } + }, [fetchAllData, isAuthenticated]); /** * Check if the current token supports the spending limit progress bar feature. @@ -565,6 +604,16 @@ const CardHome = () => { }, [isAuthenticated, isSpendingLimitSupported, priorityToken]); if (cardError) { + const isAuthError = isAuthenticated && isAuthenticationError(cardError); + + if (isHandlingAuthError || isAuthError) { + return ( + + + + ); + } + return ( ({ + type: 'REPLACE', + routeName, +})); + jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), + StackActions: { + replace: jest.fn((routeName: string) => ({ + type: 'REPLACE', + routeName, + })), + }, })); jest.mock('react-redux', () => ({ @@ -147,16 +160,18 @@ jest.mock('../../../../../../locales/i18n', () => ({ })); describe('Complete Component', () => { - const mockNavigate = jest.fn(); const mockDispatch = jest.fn(); const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + mockNavigationDispatch.mockClear(); + mockStackReplace.mockClear(); + (StackActions.replace as jest.Mock).mockImplementation(mockStackReplace); (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, + dispatch: mockNavigationDispatch, }); (useDispatch as jest.Mock).mockReturnValue(mockDispatch); @@ -228,32 +243,56 @@ describe('Complete Component', () => { expect(button.props.disabled).toBeFalsy(); }); - it('navigates to card home when pressed', async () => { + it('dispatches replace action to card home when pressed', async () => { const { getByTestId } = render(); const button = getByTestId('complete-confirm-button'); fireEvent.press(button); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('CardHome'); + expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ routeName: Routes.CARD.HOME }), + ); }); }); - it('calls navigate only once per button press', async () => { + it('dispatches replace action only once per button press', async () => { const { getByTestId } = render(); const button = getByTestId('complete-confirm-button'); fireEvent.press(button); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigationDispatch).toHaveBeenCalledTimes(1); }); fireEvent.press(button); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledTimes(2); - expect(mockNavigate).toHaveBeenCalledWith('CardHome'); + expect(mockNavigationDispatch).toHaveBeenCalledTimes(2); + expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME); + }); + }); + + it('falls back to authentication flow when token is missing', async () => { + const { getCardBaanxToken } = jest.requireMock( + '../../util/cardTokenVault', + ); + getCardBaanxToken.mockResolvedValueOnce({ + success: false, + }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('complete-confirm-button')); + + await waitFor(() => { + expect(mockStackReplace).toHaveBeenCalledWith( + Routes.CARD.AUTHENTICATION, + ); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ routeName: Routes.CARD.AUTHENTICATION }), + ); }); }); }); @@ -265,14 +304,17 @@ describe('Complete Component', () => { expect(useNavigation).toHaveBeenCalledTimes(1); }); - it('navigates to correct route on continue', async () => { + it('dispatches replace action to correct route on continue', async () => { const { getByTestId } = render(); const button = getByTestId('complete-confirm-button'); fireEvent.press(button); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('CardHome'); + expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ routeName: Routes.CARD.HOME }), + ); }); }); }); @@ -403,7 +445,10 @@ describe('Complete Component', () => { // Verify navigation to final destination await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('CardHome'); + expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ routeName: Routes.CARD.HOME }), + ); }); }); @@ -418,7 +463,10 @@ describe('Complete Component', () => { fireEvent.press(button); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('CardHome'); + expect(mockStackReplace).toHaveBeenCalledWith(Routes.CARD.HOME); + expect(mockNavigationDispatch).toHaveBeenCalledWith( + expect.objectContaining({ routeName: Routes.CARD.HOME }), + ); }); }); }); diff --git a/app/components/UI/Card/components/Onboarding/Complete.tsx b/app/components/UI/Card/components/Onboarding/Complete.tsx index 87cc620bf26..9fee9f72478 100644 --- a/app/components/UI/Card/components/Onboarding/Complete.tsx +++ b/app/components/UI/Card/components/Onboarding/Complete.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; +import { StackActions, useNavigation } from '@react-navigation/native'; import OnboardingStep from './OnboardingStep'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -44,11 +44,12 @@ const Complete = () => { try { const token = await getCardBaanxToken(); if (token.success && token.tokenData?.accessToken) { - navigation.navigate(Routes.CARD.HOME); + dispatch(resetOnboardingState()); + navigation.dispatch(StackActions.replace(Routes.CARD.HOME)); } else { - navigation.navigate(Routes.CARD.AUTHENTICATION); + dispatch(resetOnboardingState()); + navigation.dispatch(StackActions.replace(Routes.CARD.AUTHENTICATION)); } - dispatch(resetOnboardingState()); } catch (error) { Logger.log('Complete::handleContinue error', error); } finally { diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx index 1c04e4d7697..a4f539fa62d 100644 --- a/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/MailingAddress.test.tsx @@ -440,6 +440,7 @@ describe('MailingAddress Component', () => { isError: false, error: null, consentSetId: null, + getOnboardingConsentSetByOnboardingId: jest.fn(), clearError: jest.fn(), reset: jest.fn(), }); @@ -500,7 +501,7 @@ describe('MailingAddress Component', () => { }, }, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }); @@ -843,7 +844,7 @@ describe('MailingAddress Component', () => { mockUseRegistrationSettings.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }); @@ -860,7 +861,7 @@ describe('MailingAddress Component', () => { mockUseRegistrationSettings.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: jest.fn(), }); @@ -1491,6 +1492,395 @@ describe('MailingAddress Component', () => { }); }); + describe('Defensive Consent Management', () => { + let mockRegisterAddress: jest.Mock; + let mockGetOnboardingConsentSetByOnboardingId: jest.Mock; + let mockCreateOnboardingConsent: jest.Mock; + let mockLinkUserToConsent: jest.Mock; + let mockDispatch: jest.Mock; + + beforeEach(() => { + mockRegisterAddress = jest.fn(); + mockGetOnboardingConsentSetByOnboardingId = jest.fn(); + mockCreateOnboardingConsent = jest.fn(); + mockLinkUserToConsent = jest.fn(); + mockDispatch = jest.fn(); + + const { useDispatch } = jest.requireMock('react-redux'); + useDispatch.mockReturnValue(mockDispatch); + + const cardTokenVault = jest.requireMock('../../util/cardTokenVault'); + cardTokenVault.storeCardBaanxToken = jest + .fn() + .mockResolvedValue({ success: true }); + + const mapCountry = jest.requireMock('../../util/mapCountryToLocation'); + mapCountry.mapCountryToLocation = jest.fn().mockReturnValue('us'); + + const extractToken = jest.requireMock( + '../../util/extractTokenExpiration', + ); + extractToken.extractTokenExpiration = jest.fn().mockReturnValue(3600000); + }); + + it('creates new consent when no existing consent found', async () => { + // Given: No consent exists and registration will succeed + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null); + mockCreateOnboardingConsent.mockResolvedValue('new-consent-123'); + mockRegisterAddress.mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-123', email: 'test@example.com' }, + }); + + mockUseRegisterMailingAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '94102'); + fireEvent.press(getByTestId('state-select')); + + const button = getByTestId('mailing-address-continue-button'); + + // When: User submits the form + await act(async () => { + fireEvent.press(button); + }); + + // Then: Should check for existing consent and create new one + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + await waitFor(() => { + expect(mockCreateOnboardingConsent).toHaveBeenCalledWith('test-id'); + }); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'new-consent-123', + 'user-123', + ); + }); + }); + + it('reuses existing incomplete consent', async () => { + // Given: Incomplete consent exists + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({ + consentSetId: 'existing-consent-456', + userId: null, + completedAt: null, + }); + mockRegisterAddress.mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-123', email: 'test@example.com' }, + }); + + mockUseRegisterMailingAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '94102'); + fireEvent.press(getByTestId('state-select')); + + const button = getByTestId('mailing-address-continue-button'); + + // When: User submits the form + await act(async () => { + fireEvent.press(button); + }); + + // Then: Should reuse existing consent without creating new one + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'existing-consent-456', + 'user-123', + ); + }); + }); + + it('skips consent operations when consent already completed', async () => { + // Given: Completed consent exists + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue({ + consentSetId: 'completed-consent-789', + userId: 'user-123', + completedAt: '2024-01-01T00:00:00.000Z', + }); + mockRegisterAddress.mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-123', email: 'test@example.com' }, + }); + + mockUseRegisterMailingAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '94102'); + fireEvent.press(getByTestId('state-select')); + + const button = getByTestId('mailing-address-continue-button'); + + // When: User submits the form + await act(async () => { + fireEvent.press(button); + }); + + // Then: Should check for existing consent but skip all consent operations + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + expect(mockLinkUserToConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('CardOnboardingComplete'); + }); + }); + + it('uses existing consent set ID from Redux when available', async () => { + // Given: Consent ID exists in Redux + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: 'US', + onboardingId: 'test-id', + consentSetId: 'redux-consent-999', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + + mockRegisterAddress.mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-123', email: 'test@example.com' }, + }); + + mockUseRegisterMailingAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '94102'); + fireEvent.press(getByTestId('state-select')); + + const button = getByTestId('mailing-address-continue-button'); + + // When: User submits the form + await act(async () => { + fireEvent.press(button); + }); + + // Then: Should use Redux consent ID without checking API + expect(mockGetOnboardingConsentSetByOnboardingId).not.toHaveBeenCalled(); + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'redux-consent-999', + 'user-123', + ); + }); + }); + + it('clears consent set ID from Redux after linking consent', async () => { + // Given: No consent exists + mockGetOnboardingConsentSetByOnboardingId.mockResolvedValue(null); + mockCreateOnboardingConsent.mockResolvedValue('new-consent-123'); + mockRegisterAddress.mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-123', email: 'test@example.com' }, + }); + + mockUseRegisterMailingAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '94102'); + fireEvent.press(getByTestId('state-select')); + + const button = getByTestId('mailing-address-continue-button'); + + // When: User submits the form and consent is linked + await act(async () => { + fireEvent.press(button); + }); + + // Then: Should dispatch action to clear consent set ID after linking + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'new-consent-123', + 'user-123', + ); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('setConsentSetId'), + payload: null, + }), + ); + }); + }); + }); + describe('Additional Validation Edge Cases', () => { it('disables button when onboarding ID is missing', () => { const { useSelector } = jest.requireMock('react-redux'); diff --git a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx index d3930d0e5ca..26840eb7afa 100644 --- a/app/components/UI/Card/components/Onboarding/MailingAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/MailingAddress.tsx @@ -14,6 +14,7 @@ import { selectConsentSetId, selectOnboardingId, selectSelectedCountry, + setConsentSetId, setIsAuthenticatedCard, setUserCardLocation, } from '../../../../../core/redux/slices/card'; @@ -38,7 +39,11 @@ const MailingAddress = () => { const consentSetId = useSelector(selectConsentSetId); const { trackEvent, createEventBuilder } = useMetrics(); - const { linkUserToConsent } = useRegisterUserConsent(); + const { + createOnboardingConsent, + linkUserToConsent, + getOnboardingConsentSetByOnboardingId, + } = useRegisterUserConsent(); const [addressLine1, setAddressLine1] = useState(''); const [addressLine2, setAddressLine2] = useState(''); @@ -174,10 +179,36 @@ const MailingAddress = () => { dispatch(setUserCardLocation(location)); } - // Step 10: Link consent to user (complete audit trail) - // This should only happen if we have a consentSetId from the PhysicalAddress step - if (consentSetId) { - await linkUserToConsent(consentSetId, updatedUser.id); + // Step 10: Handle consent with defensive checks (similar to PhysicalAddress) + // Defensive fallback in case Redux state was lost or consent creation failed + let finalConsentSetId = consentSetId; + let shouldLinkConsent = true; + + if (!finalConsentSetId) { + // Fallback: Check if consent already exists for this onboarding + const consentSet = + await getOnboardingConsentSetByOnboardingId(onboardingId); + + if (consentSet) { + // Check if consent is already completed (both fields must be present) + if (consentSet.completedAt && consentSet.userId) { + // Consent already linked - skip consent operations + shouldLinkConsent = false; + } else { + // Consent exists but not completed - reuse it + finalConsentSetId = consentSet.consentSetId; + } + } else { + // Safety net: Create consent if it doesn't exist + // This shouldn't normally happen, but protects against edge cases + finalConsentSetId = await createOnboardingConsent(onboardingId); + } + } + + // Link consent to user (only if needed) + if (shouldLinkConsent && finalConsentSetId) { + await linkUserToConsent(finalConsentSetId, updatedUser.id); + dispatch(setConsentSetId(null)); } // Registration complete diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx index 556074efb96..6bf5191702e 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.test.tsx @@ -433,6 +433,7 @@ describe('PhysicalAddress Component', () => { mockUseRegisterUserConsent.mockReturnValue({ createOnboardingConsent: jest.fn(), linkUserToConsent: jest.fn(), + getOnboardingConsentSetByOnboardingId: jest.fn().mockResolvedValue(null), isLoading: false, isSuccess: false, isError: false, @@ -498,7 +499,7 @@ describe('PhysicalAddress Component', () => { }, }, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }); @@ -843,6 +844,9 @@ describe('PhysicalAddress Component', () => { describe('Navigation', () => { it('navigates to mailing address when same address is not checked', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue(null); const mockCreateOnboardingConsent = jest .fn() .mockResolvedValue('consent-set-123'); @@ -865,6 +869,8 @@ describe('PhysicalAddress Component', () => { mockUseRegisterUserConsent.mockReturnValue({ createOnboardingConsent: mockCreateOnboardingConsent, linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, isLoading: false, isSuccess: false, isError: false, @@ -911,6 +917,12 @@ describe('PhysicalAddress Component', () => { fireEvent.press(button); }); + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + await waitFor(() => { expect(mockCreateOnboardingConsent).toHaveBeenCalledWith('test-id'); }); @@ -937,6 +949,9 @@ describe('PhysicalAddress Component', () => { }); it('navigates to complete when same address is checked and access token is present', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue(null); const mockCreateOnboardingConsent = jest .fn() .mockResolvedValue('consent-set-123'); @@ -959,6 +974,8 @@ describe('PhysicalAddress Component', () => { mockUseRegisterUserConsent.mockReturnValue({ createOnboardingConsent: mockCreateOnboardingConsent, linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, isLoading: false, isSuccess: false, isError: false, @@ -1001,6 +1018,12 @@ describe('PhysicalAddress Component', () => { fireEvent.press(button); }); + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + await waitFor(() => { expect(mockCreateOnboardingConsent).toHaveBeenCalledWith('test-id'); }); @@ -1032,6 +1055,471 @@ describe('PhysicalAddress Component', () => { }); }); + describe('Consent Management', () => { + it('creates new consent when no existing consent found', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue(null); + const mockCreateOnboardingConsent = jest + .fn() + .mockResolvedValue('consent-set-123'); + const mockLinkUserToConsent = jest.fn().mockResolvedValue(undefined); + const mockRegisterAddress = jest.fn().mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-id' }, + }); + + mockUseRegisterPhysicalAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '12345'); + fireEvent.press(getByTestId('state-select')); + fireEvent.press( + getByTestId('physical-address-electronic-consent-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-account-opening-disclosure-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-terms-and-conditions-checkbox'), + ); + fireEvent.press(getByTestId('physical-address-privacy-policy-checkbox')); + + await waitFor(() => { + const button = getByTestId('physical-address-continue-button'); + expect(button.props.disabled).toBe(false); + }); + + const button = getByTestId('physical-address-continue-button'); + + await act(async () => { + fireEvent.press(button); + }); + + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + await waitFor(() => { + expect(mockCreateOnboardingConsent).toHaveBeenCalledWith('test-id'); + }); + }); + + it('reuses existing incomplete consent', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue({ + consentSetId: 'existing-consent-123', + userId: null, + completedAt: null, + }); + const mockCreateOnboardingConsent = jest.fn(); + const mockLinkUserToConsent = jest.fn().mockResolvedValue(undefined); + const mockRegisterAddress = jest.fn().mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-id' }, + }); + + mockUseRegisterPhysicalAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '12345'); + fireEvent.press(getByTestId('state-select')); + fireEvent.press( + getByTestId('physical-address-electronic-consent-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-account-opening-disclosure-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-terms-and-conditions-checkbox'), + ); + fireEvent.press(getByTestId('physical-address-privacy-policy-checkbox')); + + await waitFor(() => { + const button = getByTestId('physical-address-continue-button'); + expect(button.props.disabled).toBe(false); + }); + + const button = getByTestId('physical-address-continue-button'); + + await act(async () => { + fireEvent.press(button); + }); + + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'existing-consent-123', + 'user-id', + ); + }); + }); + + it('skips consent operations when consent already completed', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue({ + consentSetId: 'completed-consent-123', + userId: 'user-id', + completedAt: '2024-01-01T00:00:00.000Z', + }); + const mockCreateOnboardingConsent = jest.fn(); + const mockLinkUserToConsent = jest.fn(); + const mockRegisterAddress = jest.fn().mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-id' }, + }); + + mockUseRegisterPhysicalAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '12345'); + fireEvent.press(getByTestId('state-select')); + fireEvent.press( + getByTestId('physical-address-electronic-consent-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-account-opening-disclosure-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-terms-and-conditions-checkbox'), + ); + fireEvent.press(getByTestId('physical-address-privacy-policy-checkbox')); + + await waitFor(() => { + const button = getByTestId('physical-address-continue-button'); + expect(button.props.disabled).toBe(false); + }); + + const button = getByTestId('physical-address-continue-button'); + + await act(async () => { + fireEvent.press(button); + }); + + await waitFor(() => { + expect(mockGetOnboardingConsentSetByOnboardingId).toHaveBeenCalledWith( + 'test-id', + ); + }); + + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + expect(mockLinkUserToConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.ONBOARDING.COMPLETE, + ); + }); + }); + + it('uses existing consent set ID from Redux when available', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest.fn(); + const mockCreateOnboardingConsent = jest.fn(); + const mockLinkUserToConsent = jest.fn().mockResolvedValue(undefined); + const mockRegisterAddress = jest.fn().mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-id' }, + }); + + // Create store with existing consent set ID + const storeWithConsent = createTestStore({ + onboarding: { + selectedCountry: 'US', + onboardingId: 'test-id', + contactVerificationId: 'contact-id', + consentSetId: 'redux-consent-123', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + userCardLocation: 'us', + }); + + // Mock useSelector for this test + const { useSelector } = jest.requireMock('react-redux'); + useSelector.mockImplementation((selector: any) => + selector({ + card: { + onboarding: { + selectedCountry: 'US', + onboardingId: 'test-id', + consentSetId: 'redux-consent-123', + user: { + id: 'user-id', + email: 'test@example.com', + }, + }, + }, + }), + ); + + mockUseRegisterPhysicalAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '12345'); + fireEvent.press(getByTestId('state-select')); + fireEvent.press( + getByTestId('physical-address-electronic-consent-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-account-opening-disclosure-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-terms-and-conditions-checkbox'), + ); + fireEvent.press(getByTestId('physical-address-privacy-policy-checkbox')); + + await waitFor(() => { + const button = getByTestId('physical-address-continue-button'); + expect(button.props.disabled).toBe(false); + }); + + const button = getByTestId('physical-address-continue-button'); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockGetOnboardingConsentSetByOnboardingId).not.toHaveBeenCalled(); + expect(mockCreateOnboardingConsent).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'redux-consent-123', + 'user-id', + ); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + Routes.CARD.ONBOARDING.COMPLETE, + ); + }); + }); + + it('clears consent set ID from Redux after linking consent', async () => { + const mockGetOnboardingConsentSetByOnboardingId = jest + .fn() + .mockResolvedValue(null); + const mockCreateOnboardingConsent = jest + .fn() + .mockResolvedValue('consent-set-123'); + const mockLinkUserToConsent = jest.fn().mockResolvedValue(undefined); + const mockRegisterAddress = jest.fn().mockResolvedValue({ + accessToken: 'test-token', + user: { id: 'user-id' }, + }); + const mockDispatch = jest.fn(); + + // Mock useDispatch + const { useDispatch } = jest.requireMock('react-redux'); + useDispatch.mockReturnValue(mockDispatch); + + mockUseRegisterPhysicalAddress.mockReturnValue({ + registerAddress: mockRegisterAddress, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + mockUseRegisterUserConsent.mockReturnValue({ + createOnboardingConsent: mockCreateOnboardingConsent, + linkUserToConsent: mockLinkUserToConsent, + getOnboardingConsentSetByOnboardingId: + mockGetOnboardingConsentSetByOnboardingId, + isLoading: false, + isSuccess: false, + isError: false, + error: null, + consentSetId: null, + clearError: jest.fn(), + reset: jest.fn(), + }); + + const { getByTestId } = render( + + + , + ); + + // Given: User fills all required fields + fireEvent.changeText(getByTestId('address-line-1-input'), '123 Main St'); + fireEvent.changeText(getByTestId('city-input'), 'San Francisco'); + fireEvent.changeText(getByTestId('zip-code-input'), '12345'); + fireEvent.press(getByTestId('state-select')); + fireEvent.press( + getByTestId('physical-address-electronic-consent-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-account-opening-disclosure-checkbox'), + ); + fireEvent.press( + getByTestId('physical-address-terms-and-conditions-checkbox'), + ); + fireEvent.press(getByTestId('physical-address-privacy-policy-checkbox')); + + await waitFor(() => { + const button = getByTestId('physical-address-continue-button'); + expect(button.props.disabled).toBe(false); + }); + + const button = getByTestId('physical-address-continue-button'); + + // When: User submits the form and consent is linked + await act(async () => { + fireEvent.press(button); + }); + + await waitFor(() => { + expect(mockLinkUserToConsent).toHaveBeenCalledWith( + 'consent-set-123', + 'user-id', + ); + }); + + // Then: Should dispatch action to clear consent set ID after linking + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('setConsentSetId'), + payload: null, + }), + ); + }); + }); + }); + describe('Error Handling', () => { it('displays registration error when present', () => { mockUseRegisterPhysicalAddress.mockReturnValue({ @@ -1057,6 +1545,9 @@ describe('PhysicalAddress Component', () => { mockUseRegisterUserConsent.mockReturnValue({ createOnboardingConsent: jest.fn(), linkUserToConsent: jest.fn(), + getOnboardingConsentSetByOnboardingId: jest + .fn() + .mockResolvedValue(null), isLoading: false, isSuccess: false, isError: true, @@ -1102,6 +1593,9 @@ describe('PhysicalAddress Component', () => { mockUseRegisterUserConsent.mockReturnValue({ createOnboardingConsent: jest.fn(), linkUserToConsent: jest.fn(), + getOnboardingConsentSetByOnboardingId: jest + .fn() + .mockResolvedValue(null), isLoading: true, isSuccess: false, isError: false, @@ -1237,7 +1731,7 @@ describe('PhysicalAddress Component', () => { mockUseRegistrationSettings.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }); @@ -1254,7 +1748,7 @@ describe('PhysicalAddress Component', () => { mockUseRegistrationSettings.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: jest.fn(), }); diff --git a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx index 3e18f2f5028..fd6417cca97 100644 --- a/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx +++ b/app/components/UI/Card/components/Onboarding/PhysicalAddress.tsx @@ -262,6 +262,7 @@ const PhysicalAddress = () => { const { createOnboardingConsent, linkUserToConsent, + getOnboardingConsentSetByOnboardingId, isLoading: consentLoading, isError: consentIsError, error: consentError, @@ -452,12 +453,32 @@ const PhysicalAddress = () => { .build(), ); - // Step 7: Create consent record (get consentSetId for later linking) - // Only create consent if it doesn't already exist to avoid server errors + // Step 7: Create or retrieve consent record let consentSetId = existingConsentSetId; + let shouldLinkConsent = true; + if (!consentSetId) { - consentSetId = await createOnboardingConsent(onboardingId); - dispatch(setConsentSetId(consentSetId)); + // Check if consent already exists for this onboarding + const consentSet = + await getOnboardingConsentSetByOnboardingId(onboardingId); + + if (consentSet) { + // Check if consent is already completed (both fields must be present) + if (consentSet.completedAt && consentSet.userId) { + // Consent already linked - skip consent operations entirely + shouldLinkConsent = false; + consentSetId = null; + } else { + // Consent exists but not completed - reuse it + consentSetId = consentSet.consentSetId; + // Store it in Redux for future use + dispatch(setConsentSetId(consentSetId)); + } + } else { + // No consent exists - create a new one + consentSetId = await createOnboardingConsent(onboardingId); + dispatch(setConsentSetId(consentSetId)); + } } // Step 8: Register physical address @@ -493,8 +514,11 @@ const PhysicalAddress = () => { dispatch(setUserCardLocation(location)); } - // Step 10: Link consent to user (complete audit trail) - await linkUserToConsent(consentSetId, updatedUser.id); + // Step 10: Link consent to user (only if needed) + if (shouldLinkConsent && consentSetId) { + await linkUserToConsent(consentSetId, updatedUser.id); + dispatch(setConsentSetId(null)); + } // Navigate to completion screen navigation.navigate(Routes.CARD.ONBOARDING.COMPLETE); diff --git a/app/components/UI/Card/hooks/useAssetBalances.test.ts b/app/components/UI/Card/hooks/useAssetBalances.test.ts index 2615aba97a6..8f866701e51 100644 --- a/app/components/UI/Card/hooks/useAssetBalances.test.ts +++ b/app/components/UI/Card/hooks/useAssetBalances.test.ts @@ -35,6 +35,7 @@ jest.mock('../../../../../locales/i18n', () => ({ }; return translations[key] || key; }), + locale: 'en-US', default: { locale: 'en-US' }, })); jest.mock('../../../../core/Engine', () => ({ @@ -1435,4 +1436,383 @@ describe('useAssetBalances', () => { expect(balanceInfo?.rawTokenBalance).toBe(999999999.123456); }); }); + + describe('pre-calculated fiat reformatting', () => { + describe('filteredToken with tokenRateUndefined', () => { + it('shows formatted zero when balance is zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '0', + balanceFiat: 'tokenRateUndefined', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + mockFormatWithThreshold.mockReturnValue('$0.00'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$0.00'); + expect(balanceInfo?.rawFiatNumber).toBe(0); + }); + + it('shows token balance when balance is non-zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '100.5', + balanceFiat: 'tokenRateUndefined', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('100.500000 USDC'); + expect(balanceInfo?.rawFiatNumber).toBeUndefined(); + }); + }); + + describe('filteredToken with tokenBalanceLoading', () => { + it('shows formatted zero when balance is zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '0', + balanceFiat: 'tokenBalanceLoading', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + mockFormatWithThreshold.mockReturnValue('$0.00'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$0.00'); + expect(balanceInfo?.rawFiatNumber).toBe(0); + }); + + it('shows token balance when balance is non-zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '250.75', + balanceFiat: 'tokenBalanceLoading', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('250.750000 USDC'); + expect(balanceInfo?.rawFiatNumber).toBeUndefined(); + }); + }); + + describe('filteredToken with raw fiat value', () => { + it('reformats raw fiat string to proper currency format', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '100.5', + balanceFiat: '55.61632 usd', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + mockFormatWithThreshold.mockReturnValue('$55.62'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$55.62'); + expect(balanceInfo?.rawFiatNumber).toBe(55.61632); + expect(mockFormatWithThreshold).toHaveBeenCalledWith( + 55.61632, + 0.01, + 'en-US', + expect.objectContaining({ + style: 'currency', + currency: 'USD', + }), + ); + }); + + it('reformats fiat with commas in numbers', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '1000', + balanceFiat: '1,234.56 usd', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + mockFormatWithThreshold.mockReturnValue('$1,234.56'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$1,234.56'); + expect(balanceInfo?.rawFiatNumber).toBe(1234.56); + }); + + it('formats currency mismatch values using detected currency', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + mockUseTokensWithBalance.mockReturnValue([ + { + address: notEnabledToken.address?.toLowerCase() || '', + chainId: '0xe708', + balance: '100.5', + balanceFiat: '55.61632 brl', + symbol: 'USDC', + decimals: 18, + } as any, + ]); + + mockFormatWithThreshold.mockReturnValue('R$ 55,62'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('R$ 55,62'); + expect(balanceInfo?.rawFiatNumber).toBe(55.61632); + expect(mockFormatWithThreshold).toHaveBeenCalledWith( + 55.61632, + 0.01, + 'en-US', + expect.objectContaining({ + style: 'currency', + currency: 'BRL', + }), + ); + }); + }); + + describe('walletAsset with tokenRateUndefined', () => { + it('shows formatted zero when balance is zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + const walletAsset = { + address: notEnabledToken.address, + chainId: '0xe708', + symbol: 'USDC', + balance: '0', + balanceFiat: 'tokenRateUndefined', + isETH: false, + aggregators: [], + }; + + mockUseTokensWithBalance.mockReturnValue([]); + mockSelectAsset.mockReturnValue(walletAsset as any); + mockFormatWithThreshold.mockReturnValue('$0.00'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$0.00'); + expect(balanceInfo?.rawFiatNumber).toBe(0); + }); + + it('shows token balance when balance is non-zero', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + const walletAsset = { + address: notEnabledToken.address, + chainId: '0xe708', + symbol: 'USDC', + balance: '75.25', + balanceFiat: 'tokenRateUndefined', + isETH: false, + aggregators: [], + }; + + mockUseTokensWithBalance.mockReturnValue([]); + mockSelectAsset.mockReturnValue(walletAsset as any); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('75.250000 USDC'); + expect(balanceInfo?.rawFiatNumber).toBeUndefined(); + }); + }); + + describe('walletAsset with raw fiat value', () => { + it('reformats raw fiat string to proper currency format', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + const walletAsset = { + address: notEnabledToken.address, + chainId: '0xe708', + symbol: 'USDC', + balance: '500.50', + balanceFiat: '15.62376 usd', + isETH: false, + aggregators: [], + }; + + mockUseTokensWithBalance.mockReturnValue([]); + mockSelectAsset.mockReturnValue(walletAsset as any); + mockFormatWithThreshold.mockReturnValue('$15.62'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('$15.62'); + expect(balanceInfo?.rawFiatNumber).toBe(15.62376); + }); + + it('formats currency mismatch values using detected currency', () => { + const notEnabledToken = { + ...mockNotEnabledToken, + symbol: 'USDC', + availableBalance: undefined, + }; + + const walletAsset = { + address: notEnabledToken.address, + chainId: '0xe708', + symbol: 'USDC', + balance: '500.50', + balanceFiat: '15.62376 brl', + isETH: false, + aggregators: [], + }; + + mockUseTokensWithBalance.mockReturnValue([]); + mockSelectAsset.mockReturnValue(walletAsset as any); + mockFormatWithThreshold.mockReturnValue('R$ 15,62'); + + const { result } = renderHook(() => + useAssetBalances([notEnabledToken]), + ); + + const key = `${notEnabledToken.address?.toLowerCase()}-${notEnabledToken.caipChainId}-${notEnabledToken.walletAddress?.toLowerCase()}`; + const balanceInfo = result.current.get(key); + + expect(balanceInfo?.balanceFiat).toBe('R$ 15,62'); + expect(balanceInfo?.rawFiatNumber).toBe(15.62376); + expect(mockFormatWithThreshold).toHaveBeenCalledWith( + 15.62376, + 0.01, + 'en-US', + expect.objectContaining({ + style: 'currency', + currency: 'BRL', + }), + ); + }); + }); + }); }); diff --git a/app/components/UI/Card/hooks/useAssetBalances.tsx b/app/components/UI/Card/hooks/useAssetBalances.tsx index 8f938988147..88a6612312a 100644 --- a/app/components/UI/Card/hooks/useAssetBalances.tsx +++ b/app/components/UI/Card/hooks/useAssetBalances.tsx @@ -19,6 +19,11 @@ import I18n from '../../../../../locales/i18n'; import { deriveBalanceFromAssetMarketDetails } from '../../Tokens/util'; import { buildTokenIconUrl } from '../util/buildTokenIconUrl'; +const extractTrailingCurrencyCode = (value: string): string | undefined => { + const match = value.trim().match(/([A-Za-z]{3})$/); + return match ? match[1].toUpperCase() : undefined; +}; + export interface AssetBalanceInfo { asset: TokenI | undefined; balanceFiat: string; @@ -207,6 +212,7 @@ export const useAssetBalances = ( style: 'currency', currency: currentCurrency?.toUpperCase() || 'USD', }); + return { balanceFiat, rawFiatNumber: 0, @@ -314,22 +320,106 @@ export const useAssetBalances = ( // Use pre-calculated fiat from filtered token if (balanceSource === 'filteredToken' && filteredToken?.balanceFiat) { - return { - balanceFiat: filteredToken.balanceFiat, - rawFiatNumber: parseFloat( - filteredToken.balanceFiat.replace(/[^0-9.-]/g, ''), - ), - }; + // Handle special strings like "tokenRateUndefined" or "tokenBalanceLoading" + if ( + filteredToken.balanceFiat === 'tokenRateUndefined' || + filteredToken.balanceFiat === 'tokenBalanceLoading' + ) { + // Check if balance is zero + const balanceNum = parseFloat(balanceToUse.replace(',', '.')); + if (balanceNum === 0 || isNaN(balanceNum)) { + const balanceFiat = formatWithThreshold(0, 0.01, I18n.locale, { + style: 'currency', + currency: currentCurrency?.toUpperCase() || 'USD', + }); + return { balanceFiat, rawFiatNumber: 0 }; + } + + // Non-zero balance but no rate - show token balance + return { + balanceFiat: `${parseFloat(balanceToUse.replace(',', '.')).toFixed(6)} ${_token.symbol}`, + rawFiatNumber: undefined, + }; + } + + // Parse the numeric value and reformat it properly + const rawFiatNumber = parseFloat( + filteredToken.balanceFiat.replace(/[^0-9.-]/g, ''), + ); + + if (!isNaN(rawFiatNumber)) { + const originalCurrencyCode = extractTrailingCurrencyCode( + filteredToken.balanceFiat, + ); + + // Use the detected currency code if available, otherwise use current currency + const currencyToUse = + originalCurrencyCode || currentCurrency?.toUpperCase() || 'USD'; + + const balanceFiat = formatWithThreshold( + rawFiatNumber, + 0.01, + I18n.locale, + { + style: 'currency', + currency: currencyToUse, + }, + ); + + return { balanceFiat, rawFiatNumber }; + } } // Use pre-calculated fiat from wallet asset if (balanceSource === 'walletAsset' && walletAsset?.balanceFiat) { - return { - balanceFiat: walletAsset.balanceFiat, - rawFiatNumber: parseFloat( - walletAsset.balanceFiat.replace(/[^0-9.-]/g, ''), - ), - }; + // Handle special strings like "tokenRateUndefined" or "tokenBalanceLoading" + if ( + walletAsset.balanceFiat === 'tokenRateUndefined' || + walletAsset.balanceFiat === 'tokenBalanceLoading' + ) { + // Check if balance is zero + const balanceNum = parseFloat(balanceToUse.replace(',', '.')); + if (balanceNum === 0 || isNaN(balanceNum)) { + const balanceFiat = formatWithThreshold(0, 0.01, I18n.locale, { + style: 'currency', + currency: currentCurrency?.toUpperCase() || 'USD', + }); + return { balanceFiat, rawFiatNumber: 0 }; + } + + // Non-zero balance but no rate - show token balance + return { + balanceFiat: `${parseFloat(balanceToUse.replace(',', '.')).toFixed(6)} ${_token.symbol}`, + rawFiatNumber: undefined, + }; + } + + // Parse the numeric value and reformat it properly + const rawFiatNumber = parseFloat( + walletAsset.balanceFiat.replace(/[^0-9.-]/g, ''), + ); + + if (!isNaN(rawFiatNumber)) { + const originalCurrencyCode = extractTrailingCurrencyCode( + walletAsset.balanceFiat, + ); + + // Use the detected currency code if available, otherwise use current currency + const currencyToUse = + originalCurrencyCode || currentCurrency?.toUpperCase() || 'USD'; + + const balanceFiat = formatWithThreshold( + rawFiatNumber, + 0.01, + I18n.locale, + { + style: 'currency', + currency: currencyToUse, + }, + ); + + return { balanceFiat, rawFiatNumber }; + } } // For availableBalance with rates but no market data price @@ -391,6 +481,7 @@ export const useAssetBalances = ( currency: currentCurrency?.toUpperCase() || 'USD', }, ); + return { balanceFiat, rawFiatNumber: derivedBalance.balanceFiatCalculation, @@ -418,6 +509,7 @@ export const useAssetBalances = ( style: 'currency', currency: currentCurrency?.toUpperCase() || 'USD', }); + return { balanceFiat, rawFiatNumber: 0, diff --git a/app/components/UI/Card/hooks/useCardDetails.test.ts b/app/components/UI/Card/hooks/useCardDetails.test.ts index 4f703ca148e..a6dbb720d1c 100644 --- a/app/components/UI/Card/hooks/useCardDetails.test.ts +++ b/app/components/UI/Card/hooks/useCardDetails.test.ts @@ -53,7 +53,7 @@ describe('useCardDetails', () => { const mockCacheReturn = { data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }; @@ -102,7 +102,7 @@ describe('useCardDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: cardDetailsResult, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -121,7 +121,7 @@ describe('useCardDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: mockFetchData, }); @@ -156,20 +156,21 @@ describe('useCardDetails', () => { }); describe('Error Handling', () => { - it('maps useWrapWithCache error to UNKNOWN_ERROR', () => { + it('returns error state from useWrapWithCache', () => { // Given: useWrapWithCache has error + const mockError = new Error('Test error'); mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: true, + error: mockError, fetchData: mockFetchData, }); // When: Hook is rendered const { result } = renderHook(() => useCardDetails()); - // Then: Maps to UNKNOWN_ERROR - expect(result.current.error).toBe(CardErrorType.UNKNOWN_ERROR); + // Then: Returns error object + expect(result.current.error).toBe(mockError); expect(result.current.cardDetails).toBeNull(); }); @@ -383,7 +384,7 @@ describe('useCardDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: cardDetailsResult, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); diff --git a/app/components/UI/Card/hooks/useCardDetails.ts b/app/components/UI/Card/hooks/useCardDetails.ts index 751aa2fc55a..54737ba1d8b 100644 --- a/app/components/UI/Card/hooks/useCardDetails.ts +++ b/app/components/UI/Card/hooks/useCardDetails.ts @@ -124,7 +124,7 @@ const useCardDetails = () => { cardDetails: cardDetailsData?.cardDetails ?? null, warning: cardDetailsData?.warning ?? null, isLoading, - error: error ? CardErrorType.UNKNOWN_ERROR : null, + error, isLoadingPollCardStatusUntilProvisioned: state.isLoadingPollCardStatusUntilProvisioned, fetchCardDetails, diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts index 7ef8a94e081..62ed3ff39a2 100644 --- a/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts +++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.test.ts @@ -359,6 +359,37 @@ describe('useCardProviderAuthentication', () => { expect(result.current.loading).toBe(false); }); + it('handles ACCOUNT_DISABLED error with custom message from error', async () => { + const loginParams = { + location: 'us' as CardLocation, + email: 'test@example.com', + password: 'password123', + }; + + const accountDisabledMessage = + 'Your account has been disabled. Please contact support.'; + const accountDisabledError = new CardError( + CardErrorType.ACCOUNT_DISABLED, + accountDisabledMessage, + ); + mockSdk.initiateCardProviderAuthentication.mockRejectedValue( + accountDisabledError, + ); + + const { result } = renderHook(() => useCardProviderAuthentication()); + + await act(async () => { + try { + await result.current.login(loginParams); + } catch { + // Expected to throw + } + }); + + expect(result.current.error).toBe(accountDisabledMessage); + expect(result.current.loading).toBe(false); + }); + it('handles non-CardError instances with unknown error message', async () => { const loginParams = { location: 'us' as CardLocation, @@ -700,6 +731,30 @@ describe('useCardProviderAuthentication', () => { expect(result.current.otpLoading).toBe(false); }); + it('handles ACCOUNT_DISABLED error when sending OTP', async () => { + const otpParams = { + userId: 'user-123', + location: 'us' as CardLocation, + }; + + const accountDisabledMessage = + 'Your account has been disabled. Please contact support.'; + const accountDisabledError = new CardError( + CardErrorType.ACCOUNT_DISABLED, + accountDisabledMessage, + ); + mockSdk.sendOtpLogin.mockRejectedValue(accountDisabledError); + + const { result } = renderHook(() => useCardProviderAuthentication()); + + await act(async () => { + await result.current.sendOtpLogin(otpParams); + }); + + expect(result.current.otpError).toBe(accountDisabledMessage); + expect(result.current.otpLoading).toBe(false); + }); + it('throws error when SDK is not initialized', async () => { mockUseCardSDK.mockReturnValue({ sdk: null, diff --git a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts index 7b9e42921f9..29ba5e0540e 100644 --- a/app/components/UI/Card/hooks/useCardProviderAuthentication.ts +++ b/app/components/UI/Card/hooks/useCardProviderAuthentication.ts @@ -33,6 +33,8 @@ const getErrorMessage = (error: unknown): string => { return strings( 'card.card_authentication.errors.invalid_email_or_password', ); + case CardErrorType.ACCOUNT_DISABLED: + return error.message; case CardErrorType.SERVER_ERROR: return strings('card.card_authentication.errors.server_error'); case CardErrorType.UNKNOWN_ERROR: diff --git a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts index d48a797052e..fc6c518d225 100644 --- a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts +++ b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.test.ts @@ -355,7 +355,7 @@ describe('useGetCardExternalWalletDetails', () => { const mockCacheReturn = { data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }; @@ -394,7 +394,7 @@ describe('useGetCardExternalWalletDetails', () => { expect(result.current.data).toBeNull(); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); + expect(result.current.error).toBeNull(); expect(result.current.fetchData).toBe(mockFetchData); }); @@ -628,7 +628,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -649,7 +649,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -664,7 +664,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -677,7 +677,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -690,7 +690,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: mockFetchData, }); @@ -707,7 +707,21 @@ describe('useGetCardExternalWalletDetails', () => { priorityWalletDetail: null, }, isLoading: false, - error: false, + error: null, + fetchData: mockFetchData, + }); + + renderHook(() => useGetCardExternalWalletDetails(mockDelegationSettings)); + + expect(mockFetchData).not.toHaveBeenCalled(); + }); + + it('does not trigger fetch when error exists', () => { + const mockError = new Error('Previous fetch failed'); + mockUseWrapWithCache.mockReturnValue({ + data: null, + isLoading: false, + error: mockError, fetchData: mockFetchData, }); @@ -720,7 +734,7 @@ describe('useGetCardExternalWalletDetails', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); diff --git a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts index c0a55a3571f..b325c0273d4 100644 --- a/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts +++ b/app/components/UI/Card/hooks/useGetCardExternalWalletDetails.ts @@ -181,15 +181,24 @@ const useGetCardExternalWalletDetails = ( }, ); - const { data, isLoading, fetchData } = cacheResult; + const { data, isLoading, error, fetchData } = cacheResult; // Manually trigger fetch when all prerequisites are ready // This avoids the race condition where SDK isn't available on first render useEffect(() => { - if (sdk && isAuthenticated && delegationSettings && !isLoading && !data) { + if ( + sdk && + isAuthenticated && + delegationSettings && + !isLoading && + !error && + !data + ) { fetchData(); } - }, [sdk, isAuthenticated, delegationSettings, isLoading, data, fetchData]); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sdk, isAuthenticated, delegationSettings, isLoading, error, data]); return cacheResult; }; diff --git a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts index 000dd2c82e1..e9570a417e3 100644 --- a/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts +++ b/app/components/UI/Card/hooks/useGetDelegationSettings.test.ts @@ -80,7 +80,7 @@ describe('useGetDelegationSettings', () => { const mockCacheReturn = { data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }; @@ -117,7 +117,7 @@ describe('useGetDelegationSettings', () => { expect(result.current.data).toBeNull(); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); + expect(result.current.error).toBe(null); expect(result.current.fetchData).toBe(mockFetchData); }); @@ -362,7 +362,7 @@ describe('useGetDelegationSettings', () => { mockUseWrapWithCache.mockReturnValue({ data: mockDelegationSettingsResponse, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }); @@ -375,7 +375,7 @@ describe('useGetDelegationSettings', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: mockFetchData, }); @@ -388,13 +388,13 @@ describe('useGetDelegationSettings', () => { mockUseWrapWithCache.mockReturnValue({ data: null, isLoading: false, - error: true, + error: new Error('Test error'), fetchData: mockFetchData, }); const { result } = renderHook(() => useGetDelegationSettings()); - expect(result.current.error).toBe(true); + expect(result.current.error).toBeInstanceOf(Error); }); it('uses consistent cache key across renders', () => { @@ -481,7 +481,7 @@ describe('useGetDelegationSettings', () => { const { result } = renderHook(() => useGetDelegationSettings()); expect(typeof result.current.isLoading).toBe('boolean'); - expect(typeof result.current.error).toBe('boolean'); + expect(typeof result.current.error).toBe('object'); expect(typeof result.current.fetchData).toBe('function'); }); }); diff --git a/app/components/UI/Card/hooks/useLoadCardData.test.ts b/app/components/UI/Card/hooks/useLoadCardData.test.ts index e1788af53b6..849153e20e0 100644 --- a/app/components/UI/Card/hooks/useLoadCardData.test.ts +++ b/app/components/UI/Card/hooks/useLoadCardData.test.ts @@ -133,14 +133,14 @@ describe('useLoadCardData', () => { mockUseGetDelegationSettings.mockReturnValue({ data: mockDelegationSettings, isLoading: false, - error: false, + error: null, fetchData: mockFetchDelegationSettings, }); mockUseGetCardExternalWalletDetails.mockReturnValue({ data: mockExternalWalletDetails, isLoading: false, - error: false, + error: null, fetchData: mockFetchExternalWalletDetails, }); @@ -231,7 +231,7 @@ describe('useLoadCardData', () => { mockUseGetDelegationSettings.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: mockFetchDelegationSettings, }); @@ -265,7 +265,7 @@ describe('useLoadCardData', () => { mockUseCardDetails.mockReturnValue({ cardDetails: null, isLoading: false, - error: CardErrorType.UNKNOWN_ERROR, + error: new Error(CardErrorType.UNKNOWN_ERROR), warning: null, fetchCardDetails: mockFetchCardDetails, pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned, @@ -274,20 +274,24 @@ describe('useLoadCardData', () => { const { result } = renderHook(() => useLoadCardData()); - expect(result.current.error).toEqual(CardErrorType.UNKNOWN_ERROR); + expect(result.current.error).toEqual( + new Error(CardErrorType.UNKNOWN_ERROR), + ); }); it('returns error when delegation settings fetch fails', () => { mockUseGetDelegationSettings.mockReturnValue({ data: null, isLoading: false, - error: true, + error: new Error('Delegation settings error'), fetchData: mockFetchDelegationSettings, }); const { result } = renderHook(() => useLoadCardData()); - expect(result.current.error).toBe(true); + expect(result.current.error).toEqual( + new Error('Delegation settings error'), + ); }); it('returns warning from priority token', () => { @@ -417,7 +421,7 @@ describe('useLoadCardData', () => { mockUseGetCardExternalWalletDetails.mockReturnValue({ data: null, isLoading: true, - error: false, + error: null, fetchData: mockFetchExternalWalletDetails, }); @@ -436,13 +440,13 @@ describe('useLoadCardData', () => { mockUseGetCardExternalWalletDetails.mockReturnValue({ data: null, isLoading: false, - error: true, + error: new Error('External wallet error'), fetchData: mockFetchExternalWalletDetails, }); const { result } = renderHook(() => useLoadCardData()); - expect(result.current.error).toBe(true); + expect(result.current.error).toEqual(new Error('External wallet error')); }); it('returns empty array when external wallet details have no tokens', () => { @@ -453,7 +457,7 @@ describe('useLoadCardData', () => { mappedWalletDetails: [], }, isLoading: false, - error: false, + error: null, fetchData: mockFetchExternalWalletDetails, }); @@ -536,7 +540,7 @@ describe('useLoadCardData', () => { mockUseGetDelegationSettings.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchDelegationSettings, }); @@ -550,7 +554,7 @@ describe('useLoadCardData', () => { mockUseGetCardExternalWalletDetails.mockReturnValue({ data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchExternalWalletDetails, }); @@ -588,7 +592,7 @@ describe('useLoadCardData', () => { mockUseCardDetails.mockReturnValue({ cardDetails: null, isLoading: false, - error: CardErrorType.UNKNOWN_ERROR, + error: new Error(CardErrorType.UNKNOWN_ERROR), warning: null, fetchCardDetails: mockFetchCardDetails, pollCardStatusUntilProvisioned: mockPollCardStatusUntilProvisioned, diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts index 0efafd81c5f..e8e43ae073d 100644 --- a/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts +++ b/app/components/UI/Card/hooks/useRegisterUserConsent.test.ts @@ -38,10 +38,12 @@ const mockGetErrorMessage = getErrorMessage as jest.MockedFunction< describe('useRegisterUserConsent', () => { const mockCreateOnboardingConsent = jest.fn(); const mockLinkUserToConsent = jest.fn(); + const mockGetConsentSetByOnboardingId = jest.fn(); const mockSDK = { createOnboardingConsent: mockCreateOnboardingConsent, linkUserToConsent: mockLinkUserToConsent, + getConsentSetByOnboardingId: mockGetConsentSetByOnboardingId, } as unknown as CardSDK; const mockConsentResponse = { @@ -81,11 +83,97 @@ describe('useRegisterUserConsent', () => { expect(result.current.consentSetId).toBe(null); expect(typeof result.current.createOnboardingConsent).toBe('function'); expect(typeof result.current.linkUserToConsent).toBe('function'); + expect(typeof result.current.getOnboardingConsentSetByOnboardingId).toBe( + 'function', + ); expect(typeof result.current.clearError).toBe('function'); expect(typeof result.current.reset).toBe('function'); }); }); + describe('getOnboardingConsentSetByOnboardingId function', () => { + it('returns consent set when it exists', async () => { + const mockConsentSet = { + consentSetId: 'existing-consent-123', + userId: 'user-id', + completedAt: '2024-01-01T00:00:00.000Z', + }; + + mockGetConsentSetByOnboardingId.mockResolvedValue({ + consentSets: [mockConsentSet], + }); + + const { result } = renderHook(() => useRegisterUserConsent()); + + let retrievedConsentSet; + await act(async () => { + retrievedConsentSet = + await result.current.getOnboardingConsentSetByOnboardingId( + testOnboardingId, + ); + }); + + expect(mockGetConsentSetByOnboardingId).toHaveBeenCalledWith( + testOnboardingId, + ); + expect(retrievedConsentSet).toEqual(mockConsentSet); + }); + + it('returns null when no consent exists', async () => { + mockGetConsentSetByOnboardingId.mockResolvedValue(null); + + const { result } = renderHook(() => useRegisterUserConsent()); + + let retrievedConsentSet; + await act(async () => { + retrievedConsentSet = + await result.current.getOnboardingConsentSetByOnboardingId( + testOnboardingId, + ); + }); + + expect(retrievedConsentSet).toBeNull(); + }); + + it('throws error when SDK is not available', async () => { + mockUseCardSDK.mockReturnValue({ + sdk: null, + isLoading: false, + user: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + + const { result } = renderHook(() => useRegisterUserConsent()); + + await expect( + act(async () => { + await result.current.getOnboardingConsentSetByOnboardingId( + testOnboardingId, + ); + }), + ).rejects.toThrow('Card SDK not initialized'); + }); + + it('throws error when SDK call fails', async () => { + const testError = new CardError( + CardErrorType.NETWORK_ERROR, + 'Network failed', + ); + mockGetConsentSetByOnboardingId.mockRejectedValue(testError); + + const { result } = renderHook(() => useRegisterUserConsent()); + + await expect( + act(async () => { + await result.current.getOnboardingConsentSetByOnboardingId( + testOnboardingId, + ); + }), + ).rejects.toThrow(testError); + }); + }); + describe('createOnboardingConsent function', () => { describe('successful consent creation', () => { it('creates consent record for US users with eSignAct consent', async () => { @@ -687,6 +775,7 @@ describe('useRegisterUserConsent', () => { .fn() .mockResolvedValue(mockConsentResponse), linkUserToConsent: jest.fn().mockResolvedValue(undefined), + getConsentSetByOnboardingId: jest.fn().mockResolvedValue(null), } as unknown as CardSDK; mockUseCardSDK.mockReturnValue({ @@ -712,6 +801,7 @@ describe('useRegisterUserConsent', () => { .fn() .mockResolvedValue(mockConsentResponse), linkUserToConsent: jest.fn().mockResolvedValue(undefined), + getConsentSetByOnboardingId: jest.fn().mockResolvedValue(null), } as unknown as CardSDK; mockUseCardSDK.mockReturnValue({ @@ -731,6 +821,41 @@ describe('useRegisterUserConsent', () => { expect(customSDK.linkUserToConsent).toHaveBeenCalled(); }); + it('uses SDK from useCardSDK hook for getting consent set', async () => { + const mockConsentSet = { + consentSetId: 'test-consent', + userId: 'test-user', + completedAt: '2024-01-01T00:00:00.000Z', + }; + const customSDK = { + createOnboardingConsent: jest + .fn() + .mockResolvedValue(mockConsentResponse), + linkUserToConsent: jest.fn().mockResolvedValue(undefined), + getConsentSetByOnboardingId: jest.fn().mockResolvedValue({ + consentSets: [mockConsentSet], + }), + } as unknown as CardSDK; + + mockUseCardSDK.mockReturnValue({ + sdk: customSDK, + isLoading: false, + user: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + + const { result } = renderHook(() => useRegisterUserConsent()); + + await act(async () => { + await result.current.getOnboardingConsentSetByOnboardingId( + testOnboardingId, + ); + }); + + expect(customSDK.getConsentSetByOnboardingId).toHaveBeenCalled(); + }); + it('handles SDK loading state', () => { mockUseCardSDK.mockReturnValue({ sdk: mockSDK, @@ -746,6 +871,9 @@ describe('useRegisterUserConsent', () => { expect(result.current.isLoading).toBe(false); expect(typeof result.current.createOnboardingConsent).toBe('function'); expect(typeof result.current.linkUserToConsent).toBe('function'); + expect(typeof result.current.getOnboardingConsentSetByOnboardingId).toBe( + 'function', + ); }); }); @@ -832,6 +960,8 @@ describe('useRegisterUserConsent', () => { const initialFunctions = { createOnboardingConsent: result.current.createOnboardingConsent, linkUserToConsent: result.current.linkUserToConsent, + getOnboardingConsentSetByOnboardingId: + result.current.getOnboardingConsentSetByOnboardingId, clearError: result.current.clearError, reset: result.current.reset, }; @@ -844,6 +974,9 @@ describe('useRegisterUserConsent', () => { expect(result.current.linkUserToConsent).toBe( initialFunctions.linkUserToConsent, ); + expect(result.current.getOnboardingConsentSetByOnboardingId).toBe( + initialFunctions.getOnboardingConsentSetByOnboardingId, + ); expect(result.current.clearError).toBe(initialFunctions.clearError); expect(result.current.reset).toBe(initialFunctions.reset); }); @@ -883,6 +1016,7 @@ describe('useRegisterUserConsent', () => { sdk: { createOnboardingConsent: jest.fn(), linkUserToConsent: jest.fn(), + getConsentSetByOnboardingId: jest.fn(), } as unknown as CardSDK, isLoading: false, user: null, @@ -895,5 +1029,32 @@ describe('useRegisterUserConsent', () => { // Function is different due to SDK dependency change expect(result.current.linkUserToConsent).not.toBe(initialLinkFunction); }); + + it('updates getOnboardingConsentSetByOnboardingId when SDK dependency changes', () => { + const { result, rerender } = renderHook(() => useRegisterUserConsent()); + + const initialGetFunction = + result.current.getOnboardingConsentSetByOnboardingId; + + // Change SDK dependency + mockUseCardSDK.mockReturnValue({ + sdk: { + createOnboardingConsent: jest.fn(), + linkUserToConsent: jest.fn(), + getConsentSetByOnboardingId: jest.fn(), + } as unknown as CardSDK, + isLoading: false, + user: null, + setUser: jest.fn(), + logoutFromProvider: jest.fn(), + }); + + rerender(); + + // Function is different due to SDK dependency change + expect(result.current.getOnboardingConsentSetByOnboardingId).not.toBe( + initialGetFunction, + ); + }); }); }); diff --git a/app/components/UI/Card/hooks/useRegisterUserConsent.ts b/app/components/UI/Card/hooks/useRegisterUserConsent.ts index 61a51676276..4261bfcd74a 100644 --- a/app/components/UI/Card/hooks/useRegisterUserConsent.ts +++ b/app/components/UI/Card/hooks/useRegisterUserConsent.ts @@ -4,7 +4,7 @@ import { selectSelectedCountry } from '../../../../core/redux/slices/card'; import { useSelector } from 'react-redux'; import AppConstants from '../../../../core/AppConstants'; import { getErrorMessage } from '../util/getErrorMessage'; -import { Consent } from '../types'; +import { Consent, ConsentSet } from '../types'; interface UseRegisterUserConsentState { isLoading: boolean; @@ -19,6 +19,9 @@ interface UseRegisterUserConsentReturn extends UseRegisterUserConsentState { linkUserToConsent: (consentSetId: string, userId: string) => Promise; clearError: () => void; reset: () => void; + getOnboardingConsentSetByOnboardingId: ( + onboardingId: string, + ) => Promise; } /** @@ -64,6 +67,23 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { }); }, []); + const getOnboardingConsentSetByOnboardingId = useCallback( + async (onboardingId: string): Promise => { + if (!sdk) { + throw new Error('Card SDK not initialized'); + } + + const consentSetResponse = + await sdk.getConsentSetByOnboardingId(onboardingId); + if (!consentSetResponse) { + return null; + } + + return consentSetResponse.consentSets[0]; + }, + [sdk], + ); + /** * Step 7: Creates an onboarding consent record * This should be called BEFORE address registration @@ -223,6 +243,7 @@ export const useRegisterUserConsent = (): UseRegisterUserConsentReturn => { return { ...state, + getOnboardingConsentSetByOnboardingId, createOnboardingConsent, linkUserToConsent, clearError, diff --git a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts index 0fb69165b04..a2d343df2a5 100644 --- a/app/components/UI/Card/hooks/useRegistrationSettings.test.ts +++ b/app/components/UI/Card/hooks/useRegistrationSettings.test.ts @@ -35,7 +35,7 @@ describe('useRegistrationSettings', () => { const mockCacheReturn = { data: null, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }; @@ -126,7 +126,7 @@ describe('useRegistrationSettings', () => { const mockReturn = { data: mockRegistrationSettingsResponse, isLoading: false, - error: false, + error: null, fetchData: jest.fn(), }; mockUseWrapWithCache.mockReturnValue(mockReturn); @@ -140,7 +140,7 @@ describe('useRegistrationSettings', () => { const mockReturn = { data: null, isLoading: true, - error: false, + error: null, fetchData: jest.fn(), }; mockUseWrapWithCache.mockReturnValue(mockReturn); @@ -155,14 +155,16 @@ describe('useRegistrationSettings', () => { const mockReturn = { data: null, isLoading: false, - error: true, + error: new Error('Registration settings error'), fetchData: jest.fn(), }; mockUseWrapWithCache.mockReturnValue(mockReturn); const { result } = renderHook(() => useRegistrationSettings()); - expect(result.current.error).toBe(true); + expect(result.current.error).toEqual( + new Error('Registration settings error'), + ); expect(result.current.data).toBeNull(); }); @@ -171,7 +173,7 @@ describe('useRegistrationSettings', () => { const mockReturn = { data: null, isLoading: false, - error: false, + error: null, fetchData: mockFetchData, }; mockUseWrapWithCache.mockReturnValue(mockReturn); diff --git a/app/components/UI/Card/hooks/useWrapWithCache.test.ts b/app/components/UI/Card/hooks/useWrapWithCache.test.ts index 94ea8ffdca9..f512e5a8116 100644 --- a/app/components/UI/Card/hooks/useWrapWithCache.test.ts +++ b/app/components/UI/Card/hooks/useWrapWithCache.test.ts @@ -22,18 +22,43 @@ describe('useWrapWithCache', () => { fetchOnMount: true, }; + // Simulated Redux state that gets updated when dispatch is called + let mockCacheState: { + data: Record; + timestamps: Record; + }; + beforeEach(() => { jest.clearAllMocks(); + mockFetchFunction.mockReset(); + mockFetchFunction.mockImplementation(() => new Promise(() => undefined)); + + // Reset cache state + mockCacheState = { + data: {}, + timestamps: {}, + }; + + // Mock dispatch to update cache state (simulating Redux behavior) + mockDispatch.mockImplementation( + (action: { + type: string; + payload?: { key: string; data: unknown; timestamp: number }; + }) => { + if (action.type === 'card/setCacheData' && action.payload) { + mockCacheState.data[action.payload.key] = action.payload.data; + mockCacheState.timestamps[action.payload.key] = + action.payload.timestamp; + } + }, + ); mockUseDispatch.mockReturnValue(mockDispatch); - // Default Redux state - no cached data + // Mock selector to return current cache state mockUseSelector.mockImplementation((selector) => { const mockState = { card: { - cache: { - data: {}, - timestamps: {}, - }, + cache: mockCacheState, }, }; return selector(mockState); @@ -41,7 +66,7 @@ describe('useWrapWithCache', () => { }); describe('Initial State', () => { - it('should initialize with correct default state when no cached data exists', () => { + it('initializes with correct default state when no cached data exists', () => { // Given: No cached data in Redux (default state) // When: Hook is rendered with fetchOnMount disabled @@ -54,25 +79,16 @@ describe('useWrapWithCache', () => { // Then: Should have correct initial state expect(result.current.data).toBeNull(); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); + expect(result.current.error).toBeNull(); }); - it('should initialize with cached data when available in Redux', () => { + it('initializes with cached data when available in Redux', () => { const cachedData = mockData; const cachedTimestamp = Date.now() - 1000; // 1 second ago // Given: Cached data exists in Redux - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: cachedTimestamp }, - }, - }, - }; - return selector(mockState); - }); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = cachedTimestamp; // When: Hook is rendered const { result } = renderHook(() => @@ -83,7 +99,7 @@ describe('useWrapWithCache', () => { expect(result.current.data).toEqual(mockData); }); - it('should handle null cached data gracefully', () => { + it('handles null cached data gracefully', () => { // Given: Cached data is null (default state handles this) // When: Hook is rendered @@ -97,23 +113,14 @@ describe('useWrapWithCache', () => { }); describe('Cache Validation', () => { - it('should consider cache valid when data is fresh', () => { + it('considers cache valid when data is fresh', () => { // Given: Fresh cached data (within the 5 minute default cache duration) const cachedData = mockData; // Use a timestamp that's definitely fresh (30 seconds ago) const recentTimestamp = Date.now() - 30 * 1000; // 30 seconds ago - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: recentTimestamp }, - }, - }, - }; - return selector(mockState); - }); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = recentTimestamp; // When: Hook is rendered with fresh cache and fetchOnMount disabled to isolate cache logic const { result } = renderHook(() => @@ -127,23 +134,19 @@ describe('useWrapWithCache', () => { expect(mockFetchFunction).not.toHaveBeenCalled(); }); - it('should consider cache invalid when data is stale', () => { + it('considers cache invalid when data is stale', () => { const staleTimestamp = Date.now() - 6 * 60 * 1000; // 6 minutes ago (older than 5min cache duration) const cachedData = mockData; // Given: Stale cached data and fetchOnMount enabled - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: staleTimestamp }, - }, - }, - }; - return selector(mockState); - }); - mockFetchFunction.mockResolvedValue(mockData); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = staleTimestamp; + mockFetchFunction.mockImplementation( + () => + new Promise(() => { + // Never resolves – we only need to know fetch was triggered + }), + ); // When: Hook is rendered renderHook(() => @@ -154,22 +157,18 @@ describe('useWrapWithCache', () => { expect(mockFetchFunction).toHaveBeenCalledTimes(1); }); - it('should consider cache invalid when lastFetched is null', () => { + it('considers cache invalid when lastFetched is null', () => { const cachedData = mockData; // Given: Cached data without lastFetched timestamp - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: {}, // No timestamp for this key - }, - }, - }; - return selector(mockState); - }); - mockFetchFunction.mockResolvedValue(mockData); + mockCacheState.data[mockCacheKey] = cachedData; + // No timestamp for this key - intentionally omitted + mockFetchFunction.mockImplementation( + () => + new Promise(() => { + // Never resolves – we only need to know fetch was triggered + }), + ); // When: Hook is rendered renderHook(() => @@ -182,9 +181,14 @@ describe('useWrapWithCache', () => { }); describe('Fetch Behavior', () => { - it('should fetch data on mount when fetchOnMount is true and no valid cache', () => { + it('fetches data on mount when fetchOnMount is true and no valid cache', () => { // Given: No cached data and fetchOnMount enabled (default state) - mockFetchFunction.mockResolvedValue(mockData); + mockFetchFunction.mockImplementation( + () => + new Promise(() => { + // Never resolves – we only need to know fetch was triggered + }), + ); // When: Hook is rendered renderHook(() => @@ -195,7 +199,58 @@ describe('useWrapWithCache', () => { expect(mockFetchFunction).toHaveBeenCalledTimes(1); }); - it('should not fetch data on mount when fetchOnMount is false', () => { + it('prevents infinite loops by not fetching when already loading', () => { + // Given: Hook with slow fetch + mockFetchFunction.mockImplementation( + () => + new Promise(() => { + // Never resolves - simulates long fetch + }), + ); + + // When: Hook is rendered and re-renders while loading + const { rerender } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + ); + + // Force multiple re-renders while loading + rerender(); + rerender(); + rerender(); + + // Then: Should only call fetch once despite re-renders + expect(mockFetchFunction).toHaveBeenCalledTimes(1); + }); + + it('prevents retry loops by not auto-fetching when error exists', async () => { + const mockError = new Error('Network error'); + + // Given: Initial fetch fails with error + mockFetchFunction.mockRejectedValueOnce(mockError); + + // When: Hook is rendered and encounters error + const { result, waitForNextUpdate, rerender } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + ); + + await waitForNextUpdate(); + + // Then: Error state is set + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Network error'); + + // Clear the mock to track new calls + mockFetchFunction.mockClear(); + + // When: Component re-renders with error state + rerender(); + rerender(); + + // Then: Should not retry fetch automatically (user must manually retry) + expect(mockFetchFunction).not.toHaveBeenCalled(); + }); + + it('does not fetch data on mount when fetchOnMount is false', () => { const config = { ...defaultConfig, fetchOnMount: false }; // Given: fetchOnMount is disabled (default state) @@ -209,41 +264,23 @@ describe('useWrapWithCache', () => { expect(mockFetchFunction).not.toHaveBeenCalled(); }); - it('should handle successful data fetch', async () => { - // Given: No cached data and successful fetch - mockFetchFunction.mockResolvedValue(mockData); - - // Mock the selector to return updated data after dispatch - let selectorCallCount = 0; - mockUseSelector.mockImplementation((selector) => { - selectorCallCount++; - const mockState = { - card: { - cache: { - // After the first few calls (initial render), simulate updated cache - data: selectorCallCount > 2 ? { [mockCacheKey]: mockData } : {}, - timestamps: - selectorCallCount > 2 ? { [mockCacheKey]: Date.now() } : {}, - }, - }, - }; - return selector(mockState); - }); + it('stores fetched data when fetchData resolves', async () => { + // Given: No cached data and manual fetch + const fetchPromise = Promise.resolve(mockData); + mockFetchFunction.mockReturnValue(fetchPromise); - // When: Hook is rendered and data is fetched - const { result, waitForNextUpdate } = renderHook(() => + // When: Hook is rendered with fetchOnMount disabled + const { result } = renderHook(() => useWrapWithCache(mockCacheKey, mockFetchFunction, { - fetchOnMount: true, + fetchOnMount: false, }), ); - // Wait for the fetch to complete - await waitForNextUpdate(); + await act(async () => { + await result.current.fetchData(); + }); - // Then: Should update state with fetched data - expect(result.current.data).toEqual(mockData); - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); + // Then: Should dispatch action with fetched data expect(mockDispatch).toHaveBeenCalledWith({ type: 'card/setCacheData', payload: { @@ -252,9 +289,10 @@ describe('useWrapWithCache', () => { timestamp: expect.any(Number), }, }); + expect(result.current.isLoading).toBe(false); }); - it('should handle fetch errors', async () => { + it('handles Error instances from fetch failures', async () => { const mockError = new Error('Test error'); // Given: No cached data and fetch will fail (default state) @@ -265,17 +303,53 @@ describe('useWrapWithCache', () => { useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), ); - // Wait for the fetch to complete await waitForNextUpdate(); - // Then: Should update state with error + // Then: Should update state with error object expect(result.current.data).toBeNull(); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(true); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Test error'); expect(mockDispatch).not.toHaveBeenCalled(); }); - it('should set loading state during fetch', () => { + it('converts non-Error objects to Error instances', async () => { + const nonErrorObject = { message: 'API returned error', code: 500 }; + + // Given: Fetch fails with non-Error object + mockFetchFunction.mockRejectedValue(nonErrorObject); + + // When: Hook is rendered and fetch fails + const { result, waitForNextUpdate } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + ); + + await waitForNextUpdate(); + + // Then: Should convert to Error instance + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('[object Object]'); + }); + + it('converts string errors to Error instances', async () => { + const stringError = 'Something went wrong'; + + // Given: Fetch fails with string error + mockFetchFunction.mockRejectedValue(stringError); + + // When: Hook is rendered and fetch fails + const { result, waitForNextUpdate } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + ); + + await waitForNextUpdate(); + + // Then: Should convert to Error instance + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Something went wrong'); + }); + + it('sets loading state during fetch', () => { // Given: No cached data and slow fetch (default state) mockFetchFunction.mockImplementation( () => @@ -291,39 +365,28 @@ describe('useWrapWithCache', () => { // Then: Should be in loading state expect(result.current.isLoading).toBe(true); - expect(result.current.error).toBe(false); + expect(result.current.error).toBeNull(); }); }); describe('Manual Fetch', () => { - it('should allow manual data fetching via fetchData function', async () => { - // Given: Hook with cached data + it('allows manual data fetching via fetchData function', async () => { + // Given: Hook with cached data and fetchOnMount disabled const cachedData = mockData; const cachedTimestamp = Date.now() - 1000; - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: cachedTimestamp }, - }, - }, - }; - return selector(mockState); - }); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = cachedTimestamp; const newData = { id: 2, name: 'New Data' }; mockFetchFunction.mockResolvedValue(newData); // When: Manual fetch is triggered const { result } = renderHook(() => - useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + useWrapWithCache(mockCacheKey, mockFetchFunction, { + fetchOnMount: false, // Disable auto-fetch + }), ); - // Clear any initial calls from useEffect - mockFetchFunction.mockClear(); - mockDispatch.mockClear(); - await act(async () => { await result.current.fetchData(); }); @@ -340,23 +403,14 @@ describe('useWrapWithCache', () => { }); }); - it('should handle manual fetch errors', async () => { + it('handles manual fetch errors', async () => { const mockError = new Error('Test error'); // Given: Hook with cached data const cachedData = mockData; const cachedTimestamp = Date.now() - 1000; - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: cachedTimestamp }, - }, - }, - }; - return selector(mockState); - }); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = cachedTimestamp; mockFetchFunction.mockRejectedValue(mockError); // When: Manual fetch fails @@ -370,29 +424,21 @@ describe('useWrapWithCache', () => { // Then: Should update error state but keep cached data expect(result.current.data).toEqual(mockData); // Should keep cached data - expect(result.current.error).toBe(true); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('Test error'); expect(result.current.isLoading).toBe(false); }); }); describe('Configuration Options', () => { - it('should respect custom cache duration', () => { + it('respects custom cache duration', () => { // Given: Fresh cached data with custom cache duration const cachedData = mockData; const recentTimestamp = Date.now() - 30000; // 30 seconds ago const customConfig = { cacheDuration: 60000, fetchOnMount: false }; // 1 minute cache, no fetch on mount - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: recentTimestamp }, - }, - }, - }; - return selector(mockState); - }); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = recentTimestamp; // When: Hook is rendered with custom cache duration renderHook(() => @@ -403,24 +449,20 @@ describe('useWrapWithCache', () => { expect(mockFetchFunction).not.toHaveBeenCalled(); }); - it('should handle zero cache duration (always fetch)', () => { + it('handles zero cache duration by always fetching', () => { // Given: Cached data with zero cache duration (always fetch) const cachedData = mockData; const recentTimestamp = Date.now() - 1000; // 1 second ago const zeroConfig = { cacheDuration: 0, fetchOnMount: true }; // Always fetch - mockUseSelector.mockImplementation((selector) => { - const mockState = { - card: { - cache: { - data: { [mockCacheKey]: cachedData }, - timestamps: { [mockCacheKey]: recentTimestamp }, - }, - }, - }; - return selector(mockState); - }); - mockFetchFunction.mockResolvedValue(mockData); + mockCacheState.data[mockCacheKey] = cachedData; + mockCacheState.timestamps[mockCacheKey] = recentTimestamp; + mockFetchFunction.mockImplementation( + () => + new Promise(() => { + // Never resolves – we only need to know fetch was triggered + }), + ); // When: Hook is rendered with zero cache duration renderHook(() => @@ -433,7 +475,7 @@ describe('useWrapWithCache', () => { }); describe('Redux Integration', () => { - it('should use correct selector for cache key', () => { + it('uses correct selector for cache key', () => { // Given: Hook with specific cache key (default state) // When: Hook is rendered @@ -445,16 +487,20 @@ describe('useWrapWithCache', () => { expect(mockUseSelector).toHaveBeenCalledWith(expect.any(Function)); }); - it('should dispatch cache updates with correct structure', async () => { - // Given: No cached data (default state) + it('dispatches cache updates with correct structure', async () => { + // Given: No cached data and manual fetch trigger mockFetchFunction.mockResolvedValue(mockData); - // When: Data is fetched successfully - const { waitForNextUpdate } = renderHook(() => - useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + // When: Data is fetched via fetchData + const { result } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, { + fetchOnMount: false, + }), ); - await waitForNextUpdate(); + await act(async () => { + await result.current.fetchData(); + }); // Then: Should dispatch correct action structure expect(mockDispatch).toHaveBeenCalledWith({ @@ -465,45 +511,54 @@ describe('useWrapWithCache', () => { timestamp: expect.any(Number), }, }); + expect(result.current.isLoading).toBe(false); }); }); describe('Edge Cases', () => { - it('should handle fetch function that returns null', async () => { - // Given: Fetch function returns null (default state) + it('skips caching when fetch returns null', async () => { + // Given: Fetch function returns null via manual fetch mockFetchFunction.mockResolvedValue(null); - // When: Hook fetches data - const { result, waitForNextUpdate } = renderHook(() => - useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + // When: fetchData is called manually + const { result } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, { + fetchOnMount: false, + }), ); - await waitForNextUpdate(); + await act(async () => { + await result.current.fetchData(); + }); - // Then: Should handle null return value but NOT cache it (null indicates missing dependencies) - expect(result.current.data).toBeNull(); - expect(result.current.error).toBe(false); - // Null values are not cached to prevent caching "null" responses when dependencies aren't ready + // Then: Should not cache null responses expect(mockDispatch).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeNull(); }); - it('should handle fetch function that returns undefined', async () => { - // Given: Fetch function returns undefined (default state) + it('skips caching when fetch returns undefined', async () => { + // Given: Fetch function returns undefined and manual fetch trigger mockFetchFunction.mockResolvedValue(undefined); - // When: Hook fetches data - const { result, waitForNextUpdate } = renderHook(() => - useWrapWithCache(mockCacheKey, mockFetchFunction, defaultConfig), + // When: fetchData is called manually + const { result } = renderHook(() => + useWrapWithCache(mockCacheKey, mockFetchFunction, { + fetchOnMount: false, + }), ); - await waitForNextUpdate(); + await act(async () => { + await result.current.fetchData(); + }); - // Then: Should handle undefined return value + // Then: Should not cache undefined responses + expect(mockDispatch).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeNull(); - expect(result.current.error).toBe(false); }); - it('should handle empty cache key', () => { + it('handles empty cache key', () => { // Given: Empty cache key const emptyCacheKey = ''; @@ -521,7 +576,7 @@ describe('useWrapWithCache', () => { }); describe('Memory Management', () => { - it('should cleanup properly on unmount', () => { + it('cleans up properly on unmount', () => { // Given: Hook with ongoing fetch (default state) mockFetchFunction.mockImplementation( () => diff --git a/app/components/UI/Card/hooks/useWrapWithCache.ts b/app/components/UI/Card/hooks/useWrapWithCache.ts index d7585addb0b..80b94e2a63c 100644 --- a/app/components/UI/Card/hooks/useWrapWithCache.ts +++ b/app/components/UI/Card/hooks/useWrapWithCache.ts @@ -21,8 +21,8 @@ interface CacheHookReturn { data: T | null; /** Loading state - true when actively fetching */ isLoading: boolean; - /** Error state - true if last fetch failed */ - error: boolean; + /** Error object if last fetch failed, null otherwise */ + error: Error | null; /** Function to manually trigger data fetch */ fetchData: () => Promise; } @@ -63,7 +63,7 @@ export const useWrapWithCache = ( const dispatch = useDispatch(); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(false); + const [error, setError] = useState(null); // Get cached data and timestamp from Redux store (card.cache) const cachedData = useSelector( @@ -88,26 +88,28 @@ export const useWrapWithCache = ( // Fetch function that updates cache const fetchData: () => Promise = useCallback(async () => { setIsLoading(true); - setError(false); + setError(null); try { const result = await fetchFn(); + const normalizedResult = result ?? null; // Only update cache if we got actual data (not null from missing dependencies) // This prevents caching "null" responses when dependencies aren't ready - if (result !== null) { + if (normalizedResult !== null) { dispatch( setCacheData({ key: cacheKey, - data: result, + data: normalizedResult, timestamp: Date.now(), }), ); } - return result; + return normalizedResult; } catch (err) { - setError(true); + const errorObject = err instanceof Error ? err : new Error(String(err)); + setError(errorObject); return null; } finally { setIsLoading(false); @@ -122,6 +124,17 @@ export const useWrapWithCache = ( return; } + // Don't fetch if already loading (prevents infinite loops on re-renders) + if (isLoading) { + return; + } + + // Don't fetch if there was an error (prevents retry loops on failed requests) + // User must manually call fetchData() to retry + if (error) { + return; + } + // If cache is valid and we have data, don't fetch if (cacheIsValid && cachedData !== null) { return; @@ -133,7 +146,7 @@ export const useWrapWithCache = ( // when the fetch function reference changes but the cache is still valid // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cacheIsValid, cachedData, fetchOnMount]); + }, [cacheIsValid, cachedData, fetchOnMount, isLoading, error]); // Determine loading state: only show loading if actively fetching AND no cached data const shouldShowLoading = isLoading && (!cachedData || !cacheIsValid); diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts index 69c01c3c8b8..3d1563ef5bb 100644 --- a/app/components/UI/Card/sdk/CardSDK.test.ts +++ b/app/components/UI/Card/sdk/CardSDK.test.ts @@ -1152,6 +1152,25 @@ describe('CardSDK', () => { ); }); + it('throws ACCOUNT_DISABLED error when account has been disabled', async () => { + const disabledAccountMessage = + 'Your account has been disabled. Please contact support.'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({ + message: disabledAccountMessage, + }), + }); + + await expect(cardSDK.login(mockLoginData)).rejects.toThrow(CardError); + + await expect(cardSDK.login(mockLoginData)).rejects.toMatchObject({ + type: CardErrorType.ACCOUNT_DISABLED, + message: disabledAccountMessage, + }); + }); + it('throws error with invalid credentials', async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: false, @@ -2756,6 +2775,169 @@ describe('CardSDK', () => { }); }); + describe('getConsentSetByOnboardingId', () => { + const onboardingId = 'onboarding123'; + + it('gets consent set successfully', async () => { + const mockResponse = { + consentSetId: 'consentSet123', + onboardingId, + consents: [ + { + consentId: 'consent1', + title: 'Terms of Service', + accepted: true, + }, + ], + policyType: 'US', + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const result = await cardSDK.getConsentSetByOnboardingId(onboardingId); + + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v2/consent/onboarding/${onboardingId}`), + expect.objectContaining({ + method: 'GET', + }), + ); + }); + + it('returns null when consent set not found (404)', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 404, + json: jest.fn().mockResolvedValue({ + message: 'Consent set not found', + }), + }); + + const result = await cardSDK.getConsentSetByOnboardingId(onboardingId); + + expect(result).toBeNull(); + }); + + it('throws CONFLICT_ERROR for 4xx errors (except 404)', async () => { + const errorMessage = 'Bad request - invalid onboarding id'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 400, + json: jest.fn().mockResolvedValue({ + message: errorMessage, + }), + }); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.CONFLICT_ERROR, + message: errorMessage, + }); + }); + + it('throws CONFLICT_ERROR with default message when response has no message', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({}), + }); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.CONFLICT_ERROR, + message: 'Failed to get consent set by onboarding id', + }); + }); + + it('throws SERVER_ERROR for 5xx errors', async () => { + const errorMessage = 'Internal server error'; + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + json: jest.fn().mockResolvedValue({ + message: errorMessage, + }), + }); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.SERVER_ERROR, + message: errorMessage, + }); + }); + + it('throws SERVER_ERROR with default message when response parsing fails', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 503, + json: jest.fn().mockRejectedValue(new Error('Invalid JSON')), + }); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.SERVER_ERROR, + message: 'Server error while getting consent set by onboarding id', + }); + }); + + it('handles network errors', async () => { + const networkError = new Error('Network failure'); + (global.fetch as jest.Mock).mockRejectedValue(networkError); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.NETWORK_ERROR, + message: 'Network error. Please check your connection.', + }); + }); + + it('handles timeout errors from makeRequest', async () => { + const timeoutError = new Error('Request timeout'); + timeoutError.name = 'AbortError'; + (global.fetch as jest.Mock).mockRejectedValue(timeoutError); + + await expect( + cardSDK.getConsentSetByOnboardingId(onboardingId), + ).rejects.toMatchObject({ + type: CardErrorType.TIMEOUT_ERROR, + }); + }); + + it('makes unauthenticated request', async () => { + const mockResponse = { + consentSetId: 'consentSet123', + onboardingId, + }; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + await cardSDK.getConsentSetByOnboardingId(onboardingId); + + // Verify it's called without Authorization header (unauthenticated) + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'GET', + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); + }); + describe('linkUserToConsent', () => { it('links user to consent successfully', async () => { const consentSetId = 'consentSet123'; diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts index 8278e82ac00..db216167b09 100644 --- a/app/components/UI/Card/sdk/CardSDK.ts +++ b/app/components/UI/Card/sdk/CardSDK.ts @@ -45,6 +45,7 @@ import { CardNetwork, DelegationSettingsResponse, DelegationSettingsNetwork, + GetOnboardingConsentResponse, } from '../types'; import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; import { getDefaultBaanxApiBaseUrlForMetaMaskEnv } from '../util/mapBaanxApiUrl'; @@ -586,6 +587,13 @@ export class CardSDK { // If we can't parse response, continue without it } + if (responseBody?.message?.includes('Your account has been disabled')) { + throw new CardError( + CardErrorType.ACCOUNT_DISABLED, + responseBody?.message, + ); + } + // Handle specific HTTP status codes if ( response.status === 401 || @@ -2003,6 +2011,63 @@ export class CardSDK { } }; + getConsentSetByOnboardingId = async ( + onboardingId: string, + ): Promise => { + try { + const response = await this.makeRequest( + `/v2/consent/onboarding/${onboardingId}`, + { + method: 'GET', + }, + false, // not authenticated + ); + + if (!response.ok) { + let responseBody = null; + try { + responseBody = await response.json(); + } catch { + // If we can't parse response, continue without it + } + + if (response.status === 404) { + return null; + } + + if (response.status >= 400 && response.status < 500) { + throw new CardError( + CardErrorType.CONFLICT_ERROR, + responseBody?.message || + 'Failed to get consent set by onboarding id', + ); + } + + if (response.status >= 500) { + throw new CardError( + CardErrorType.SERVER_ERROR, + responseBody?.message || + 'Server error while getting consent set by onboarding id', + ); + } + } + + const data = await response.json(); + this.logDebugInfo('getConsentSetByOnboardingId response', data); + return data; + } catch (error) { + this.logDebugInfo('getConsentSetByOnboardingId error', error); + if (error instanceof CardError) { + throw error; + } + throw new CardError( + CardErrorType.UNKNOWN_ERROR, + 'Failed to get consent set by onboarding id', + error as Error, + ); + } + }; + createOnboardingConsent = async ( request: Omit, ): Promise => { diff --git a/app/components/UI/Card/types.ts b/app/components/UI/Card/types.ts index d2c57b57baf..6df84afc543 100644 --- a/app/components/UI/Card/types.ts +++ b/app/components/UI/Card/types.ts @@ -168,6 +168,7 @@ export enum CardErrorType { TIMEOUT_ERROR = 'TIMEOUT_ERROR', API_KEY_MISSING = 'API_KEY_MISSING', UNKNOWN_ERROR = 'UNKNOWN_ERROR', + ACCOUNT_DISABLED = 'ACCOUNT_DISABLED', VALIDATION_ERROR = 'VALIDATION_ERROR', SERVER_ERROR = 'SERVER_ERROR', NO_CARD = 'NO_CARD', @@ -341,6 +342,22 @@ export interface ConsentMetadata { version?: string; } +export interface ConsentSet { + consentSetId: string; + userId: string | null; + onboardingId: string; + tenantId: string; + completedAt: string | null; + createdAt: string; + updatedAt: string; + consents: Consent[]; +} + +export interface GetOnboardingConsentResponse { + onboardingId: string; + consentSets: ConsentSet[]; +} + export interface Consent { consentType: | 'eSignAct' diff --git a/app/components/hooks/useFeatureFlag.ts b/app/components/hooks/useFeatureFlag.ts index 1987e81be4c..4bbfb9ebc0e 100644 --- a/app/components/hooks/useFeatureFlag.ts +++ b/app/components/hooks/useFeatureFlag.ts @@ -4,6 +4,7 @@ import { selectBasicFunctionalityEnabled } from '../../selectors/settings'; export enum FeatureFlagNames { rewardsEnabled = 'rewardsEnabled', + otaUpdatesEnabled = 'otaUpdatesEnabled', } export const useFeatureFlag = (key: FeatureFlagNames) => { diff --git a/app/components/hooks/useOTAUpdates.test.ts b/app/components/hooks/useOTAUpdates.test.ts new file mode 100644 index 00000000000..56e177e8a8d --- /dev/null +++ b/app/components/hooks/useOTAUpdates.test.ts @@ -0,0 +1,302 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react-native'; +import { + checkForUpdateAsync, + fetchUpdateAsync, + reloadAsync, + UpdateCheckResultNotAvailableReason, +} from 'expo-updates'; +import { useFeatureFlag } from './useFeatureFlag'; +import { useOTAUpdates } from './useOTAUpdates'; +import Logger from '../../util/Logger'; + +jest.mock('expo-updates', () => ({ + checkForUpdateAsync: jest.fn(), + fetchUpdateAsync: jest.fn(), + reloadAsync: jest.fn(), +})); + +jest.mock('./useFeatureFlag', () => { + const actual = jest.requireActual('./useFeatureFlag'); + return { + ...actual, + useFeatureFlag: jest.fn(), + }; +}); + +jest.mock('../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +const mockManifest = { + id: '1', + createdAt: '2021-01-01', + runtimeVersion: '1.0.0', + launchAsset: { + url: 'https://example.com/asset.js', + }, + assets: [], + metadata: {}, + extra: undefined, +}; + +describe('useOTAUpdates', () => { + const mockUseFeatureFlag = useFeatureFlag as jest.MockedFunction< + typeof useFeatureFlag + >; + const mockCheckForUpdateAsync = checkForUpdateAsync as jest.MockedFunction< + typeof checkForUpdateAsync + >; + const mockFetchUpdateAsync = fetchUpdateAsync as jest.MockedFunction< + typeof fetchUpdateAsync + >; + const mockReloadAsync = reloadAsync as jest.MockedFunction< + typeof reloadAsync + >; + const mockLoggerError = Logger.error as jest.MockedFunction< + typeof Logger.error + >; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseFeatureFlag.mockReturnValue(false); + (global as unknown as { __DEV__: boolean }).__DEV__ = false; + }); + + it('returns isCheckingUpdates as false when feature flag is disabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(result.current.isCheckingUpdates).toBe(false); + }); + expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); + }); + + it('skips update check in development mode', async () => { + (global as unknown as { __DEV__: boolean }).__DEV__ = true; + mockUseFeatureFlag.mockReturnValue(true); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(result.current.isCheckingUpdates).toBe(false); + }); + expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); + }); + + it('checks for updates when feature flag is enabled', async () => { + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: false, + isRollBackToEmbedded: false, + reason: + 'noUpdateAvailableOnServer' as UpdateCheckResultNotAvailableReason, + manifest: undefined, + }); + + renderHook(() => useOTAUpdates()); + await waitFor(() => { + expect(mockCheckForUpdateAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('sets isCheckingUpdates to false when no update is available', async () => { + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: false, + isRollBackToEmbedded: false, + reason: + 'noUpdateAvailableOnServer' as UpdateCheckResultNotAvailableReason, + manifest: undefined, + }); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(result.current.isCheckingUpdates).toBe(false); + }); + }); + + it('fetches and reloads when a new update is available', async () => { + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + reason: undefined, + }); + mockFetchUpdateAsync.mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: mockManifest, + }); + mockReloadAsync.mockResolvedValue(undefined); + + renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockCheckForUpdateAsync).toHaveBeenCalledTimes(1); + expect(mockFetchUpdateAsync).toHaveBeenCalledTimes(1); + expect(mockReloadAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('sets isCheckingUpdates to false when update is fetched but not new', async () => { + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + reason: undefined, + }); + mockFetchUpdateAsync.mockResolvedValue({ + isNew: false, + manifest: undefined, + isRollBackToEmbedded: false, + }); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(result.current.isCheckingUpdates).toBe(false); + }); + expect(mockReloadAsync).not.toHaveBeenCalled(); + }); + + it('logs error and sets isCheckingUpdates to false when check fails', async () => { + const mockError = new Error('Update check failed'); + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockRejectedValue(mockError); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + mockError, + 'OTA Updates: Error checking for updates, continuing with current version', + ); + expect(result.current.isCheckingUpdates).toBe(false); + }); + }); + + it('does not block app if reload fails', async () => { + const mockError = new Error('Reload failed'); + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + reason: undefined, + }); + mockFetchUpdateAsync.mockResolvedValue({ + isNew: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + }); + mockReloadAsync.mockRejectedValue(mockError); + + const { result } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + mockError, + 'OTA Updates: Error checking for updates, continuing with current version', + ); + expect(result.current.isCheckingUpdates).toBe(false); + }); + }); + + it('checks for updates when feature flag changes from disabled to enabled', async () => { + mockUseFeatureFlag.mockReturnValue(false); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: false, + isRollBackToEmbedded: false, + reason: + 'noUpdateAvailableOnServer' as UpdateCheckResultNotAvailableReason, + manifest: undefined, + }); + + const { rerender } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); + }); + + mockUseFeatureFlag.mockReturnValue(true); + rerender(); + + await waitFor(() => { + expect(mockCheckForUpdateAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('does not check for updates again when feature flag changes from enabled to disabled', async () => { + mockUseFeatureFlag.mockReturnValueOnce(true).mockReturnValue(false); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: false, + isRollBackToEmbedded: false, + reason: + 'noUpdateAvailableOnServer' as UpdateCheckResultNotAvailableReason, + manifest: undefined, + }); + + const { rerender } = renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockCheckForUpdateAsync).toHaveBeenCalledTimes(1); + }); + + mockCheckForUpdateAsync.mockClear(); + rerender(); + + expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); + }); + + it('starts with isCheckingUpdates as true', () => { + mockUseFeatureFlag.mockReturnValue(true); + + const { result } = renderHook(() => useOTAUpdates()); + + expect(result.current.isCheckingUpdates).toBe(true); + }); + + it('calls update check, fetch, and reload in order', async () => { + mockUseFeatureFlag.mockReturnValue(true); + mockCheckForUpdateAsync.mockResolvedValue({ + isAvailable: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + reason: undefined, + }); + mockFetchUpdateAsync.mockResolvedValue({ + isNew: true, + manifest: mockManifest, + isRollBackToEmbedded: false, + }); + mockReloadAsync.mockResolvedValue(undefined); + + renderHook(() => useOTAUpdates()); + + await waitFor(() => { + expect(mockCheckForUpdateAsync).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockFetchUpdateAsync).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockReloadAsync).toHaveBeenCalled(); + }); + + const checkOrder = mockCheckForUpdateAsync.mock.invocationCallOrder[0]; + const fetchOrder = mockFetchUpdateAsync.mock.invocationCallOrder[0]; + const reloadOrder = mockReloadAsync.mock.invocationCallOrder[0]; + + expect(checkOrder).toBeLessThan(fetchOrder); + expect(fetchOrder).toBeLessThan(reloadOrder); + }); +}); diff --git a/app/components/hooks/useOTAUpdates.ts b/app/components/hooks/useOTAUpdates.ts new file mode 100644 index 00000000000..a2b34db6873 --- /dev/null +++ b/app/components/hooks/useOTAUpdates.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { + checkForUpdateAsync, + fetchUpdateAsync, + reloadAsync, +} from 'expo-updates'; +import Logger from '../../util/Logger'; +import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; + +/** + * Hook to manage OTA updates based on feature flag + * Checks for updates once when app initially opens if feature flag is enabled + * Automatically reloads the app if an update is available + * Returns isCheckingUpdates to gate rendering until check is complete + */ +export const useOTAUpdates = () => { + const otaUpdatesEnabled = useFeatureFlag(FeatureFlagNames.otaUpdatesEnabled); + const [isCheckingUpdates, setIsCheckingUpdates] = useState(true); + + useEffect(() => { + const checkForUpdates = async () => { + if (!otaUpdatesEnabled) { + setIsCheckingUpdates(false); + return; + } + + if (__DEV__) { + setIsCheckingUpdates(false); + return; + } + + try { + const update = await checkForUpdateAsync(); + + if (update.isAvailable) { + const fetchResult = await fetchUpdateAsync(); + + if (fetchResult.isNew) { + await reloadAsync(); + } else { + Logger.log('OTA Updates: Update fetched but not new'); + setIsCheckingUpdates(false); + } + } else { + Logger.log('OTA Updates: No updates available'); + setIsCheckingUpdates(false); + } + } catch (error) { + Logger.error( + error as Error, + 'OTA Updates: Error checking for updates, continuing with current version', + ); + setIsCheckingUpdates(false); + } + }; + + checkForUpdates(); + }, [otaUpdatesEnabled]); + + return { + isCheckingUpdates, + }; +}; diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 767b087630a..e325eff6dee 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -34,7 +34,7 @@ export const selectConversionRate = createSelector( }, ); -export const selectCurrencyRates = createSelector( +export const selectCurrencyRates = createDeepEqualSelector( selectCurrencyRateControllerState, (currencyRateControllerState: CurrencyRateState) => currencyRateControllerState?.currencyRates, diff --git a/app/selectors/multichain/multichain.ts b/app/selectors/multichain/multichain.ts index 5af22fbd408..978746ab62a 100644 --- a/app/selectors/multichain/multichain.ts +++ b/app/selectors/multichain/multichain.ts @@ -268,67 +268,71 @@ export const selectMultichainTokenListForAccountId = createDeepEqualSelector( }, ); -export const selectMultichainTokenListForAccountAnyChain = +export const selectMultichainTokenListForAccountsAnyChain = createDeepEqualSelector( selectMultichainBalances, selectMultichainAssets, selectMultichainAssetsMetadata, selectMultichainAssetsRates, - (_: RootState, account: InternalAccount | undefined) => account, - (multichainBalances, assets, assetsMetadata, assetsRates, account) => { - if (!account) { + (_: RootState, accounts: InternalAccount[] | undefined) => accounts, + (multichainBalances, assets, assetsMetadata, assetsRates, accounts) => { + if (!accounts || accounts.length === 0) { return []; } - const accountId = account.id; - - const assetIds = assets?.[accountId] || []; - const balances = multichainBalances?.[accountId]; - const tokens = []; - for (const assetId of assetIds) { - const { chainId, assetNamespace } = parseCaipAssetType(assetId); - - // Remove the chain filter - include tokens from all chains - const isNative = assetNamespace === 'slip44'; - const balance = balances?.[assetId] || { amount: undefined, unit: '' }; - const rate = assetsRates?.[assetId]?.rate || '0'; - const balanceInFiat = balance.amount - ? new BigNumber(balance.amount).times(rate) - : undefined; - - const assetMetadataFallback = { - name: balance.unit || '', - symbol: balance.unit || '', - fungible: true, - units: [{ name: assetId, symbol: balance.unit || '', decimals: 0 }], - }; - - const metadata = assetsMetadata[assetId] || assetMetadataFallback; - const decimals = metadata.units[0]?.decimals || 0; - - tokens.push({ - name: metadata?.name ?? '', - address: assetId, - symbol: metadata?.symbol ?? '', - image: metadata?.iconUrl, - logo: metadata?.iconUrl, - decimals, - chainId, - isNative, - balance: balance.amount, - secondary: balanceInFiat ? balanceInFiat.toString() : undefined, - string: '', - balanceFiat: balanceInFiat ? balanceInFiat.toString() : undefined, - isStakeable: false, - aggregators: [], - isETH: false, - ticker: metadata.symbol, - accountType: account.type, - }); + for (const account of accounts) { + const accountId = account.id; + + const assetIds = assets?.[accountId] || []; + const balances = multichainBalances?.[accountId]; + + for (const assetId of assetIds) { + const { chainId, assetNamespace } = parseCaipAssetType(assetId); + + // Remove the chain filter - include tokens from all chains + const isNative = assetNamespace === 'slip44'; + const balance = balances?.[assetId] || { + amount: undefined, + unit: '', + }; + const rate = assetsRates?.[assetId]?.rate || '0'; + const balanceInFiat = balance.amount + ? new BigNumber(balance.amount).times(rate) + : undefined; + + const assetMetadataFallback = { + name: balance.unit || '', + symbol: balance.unit || '', + fungible: true, + units: [{ name: assetId, symbol: balance.unit || '', decimals: 0 }], + }; + + const metadata = assetsMetadata[assetId] || assetMetadataFallback; + const decimals = metadata.units[0]?.decimals || 0; + + tokens.push({ + name: metadata?.name ?? '', + address: assetId, + symbol: metadata?.symbol ?? '', + image: metadata?.iconUrl, + logo: metadata?.iconUrl, + decimals, + chainId, + isNative, + balance: balance.amount, + secondary: balanceInFiat ? balanceInFiat.toString() : undefined, + string: '', + balanceFiat: balanceInFiat ? balanceInFiat.toString() : undefined, + isStakeable: false, + aggregators: [], + isETH: false, + ticker: metadata.symbol, + accountType: account.type, + }); + } } - return tokens; }, ); diff --git a/app/util/feature-flags/index.ts b/app/util/feature-flags/index.ts index a7b8245b428..0d651d108c1 100644 --- a/app/util/feature-flags/index.ts +++ b/app/util/feature-flags/index.ts @@ -69,6 +69,7 @@ export const getFeatureFlagDescription = (key: string): string | undefined => { tokenSearchDiscoveryEnabled: 'Token search and discovery', productSafetyDappScanningEnabled: 'DApp security scanning', minimumAppVersion: 'Minimum app version requirements', + otaUpdatesEnabled: 'OTA updates functionality', }; return descriptions[key]; }; diff --git a/docs/readme/deeplinking.md b/docs/readme/deeplinking.md index bc5eca04c30..2969ee72f30 100644 --- a/docs/readme/deeplinking.md +++ b/docs/readme/deeplinking.md @@ -12,6 +12,7 @@ - [Signature Verification](#signature-verification) → [See Verification Diagrams](./deeplinking-graphs.md#signature-creation-and-verification-detail) - [Testing Links](#testing-links) - [Security Considerations](#security-considerations) +- [Custom Schemes Explained](#custom-uri-schemes-explained) Please note that custom `metamask://...` schemed links are being phased out in favor of universal links: `https://link.metamask.io/somePath?someParam=someValue` @@ -792,3 +793,107 @@ describe('Dynamic signature verification', () => { | `rewards` | Rewards program | `_handleRewards` | | `wc` | WalletConnect | `parse` (recursive) | | `onboarding` | Fast onboarding | `_handleFastOnboarding` | + +## Custom URI Schemes Explained + +MetaMask Mobile supports four custom URI schemes, each serving a specific purpose: + +### `metamask://` - MetaMask App Actions + +The primary custom scheme for MetaMask-specific actions and features. Used for: + +- **App navigation**: `metamask://home`, `metamask://create-account` +- **Financial operations**: `metamask://swap`, `metamask://send?to=0x123` +- **Feature access**: `metamask://rewards`, `metamask://perps` +- **SDK connections**: `metamask://connect?channelId=abc&pubkey=xyz` (dapp-to-wallet pairing) + +**Flow**: Converts to universal link format internally → Routes to appropriate handler → Executes action + +**Example**: + +``` +metamask://swap?sourceToken=ETH&destinationToken=USDC&sourceAmount=1.5 +# Opens the swap screen with ETH→USDC pre-filled with 1.5 ETH### `wc://` - WalletConnect Protocol +``` + +Industry-standard protocol for connecting MetaMask to dapps via WalletConnect v2. Used for: + +- **QR code connections**: User scans WalletConnect QR code from a dapp +- **Deep link connections**: Dapp triggers connection via mobile deep link +- **Cross-app communication**: Establishes encrypted channel between dapp and wallet + +**Flow**: WalletConnect URI → WC2Manager → Connection approval modal → Establishes session + +**Example**: + +wc:abc123def456@2?relay-protocol=irn&symKey=xyz789 + +# WalletConnect v2 pairing URI (typically from QR code) + +metamask://wc?uri=wc:abc123def456@2?relay-protocol=irn&symKey=xyz789 + +# Same connection via deep link redirect**Parameters**: + +- `abc123def456` = Topic/Channel ID +- `@2` = WalletConnect protocol version +- `relay-protocol` = Relay server type +- `symKey` = Symmetric encryption key + +### `ethereum://` - EIP-681 Payment Requests + +[EIP-681](https://eips.ethereum.org/EIPS/eip-681) standard for Ethereum payment and transaction requests. Used for: + +- **Simple transfers**: Send ETH to an address +- **Token approvals**: Approve token spending +- **Contract interactions**: Call contract functions with parameters +- **QR code payments**: Payment request QR codes + +**Flow**: Parses EIP-681 format → Switches to correct network → Opens Send/Approval screen + +**Examples**: + +``` +# Simple ETH transfer +ethereum:0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb?value=1.5e18 + +# Token transfer (calls ERC-20 transfer function) +ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/transfer?address=0x123&uint256=1000000 + +# Contract function call with parameters +ethereum:0x123ABC@1/approve?address=0x456DEF&uint256=1000000000000000000 +``` + +**Supported Actions** (from `eth-url-parser`): + +- `transfer` - ERC-20 token transfers +- `approve` - Token spending approvals +- Generic sends (when only `value` parameter provided) + +**Network Handling**: The `@1` suffix specifies chain ID (e.g., `@1` = Mainnet, `@137` = Polygon) + +### `dapp://` - In-App Browser Navigation + +Opens external websites in MetaMask's built-in browser (similar to opening links in the browser tab). Used for: + +- **Direct dapp access**: Navigate to specific dapps +- **Deep paths**: Open specific pages within dapps +- **Trusted integrations**: Coinbase Commerce, payment flows + +**Flow**: Converts `dapp://` → `https://` → Opens in MetaMask browser → No security modal + +**Examples**: + +``` +# Basic dapp opening +dapp://uniswap.org +# → Opens https://uniswap.org in MetaMask browser + +# With deep path +dapp://app.1inch.io/#/1/simple/swap/ETH/DAI +# → Opens https://app.1inch.io/#/1/simple/swap/ETH/DAI + +# Via universal link (shows security modal) +https://metamask.app.link/dapp/commerce.coinbase.com +``` + +**Key Difference**: `dapp://` bypasses the interstitial warning modal, making it ideal for trusted payment flows and in-app navigation. diff --git a/locales/languages/es.json b/locales/languages/es.json index 59956ce79a3..3884bae51e4 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -6131,8 +6131,8 @@ "title": "Habilitar las funciones de la tarjeta MetaMask", "description": "Cambia tu token de gasto y red iniciando sesión con tu correo electrónico y contraseña de Crypto Life.", "verify_account_button": "Iniciar sesión", - "non_cardholder_title": "Bienvenido a Trajeta MetaMask", - "non_cardholder_description": "Tarjeta MetaMask es la forma gratuita y sencilla de gastar tus criptomonedas, con grandes recompensas en cadena.", + "non_cardholder_title": "Bienvenido a la Tarjeta MetaMask", + "non_cardholder_description": "La forma gratuita y sencilla de gastar tus criptomonedas y obtener grandes recompensas onchain.", "non_cardholder_verify_account_button": "Comenzar", "sign_up": { "title": "Inscribirse",