diff --git a/app/components/UI/Bridge/hooks/useFetchPopularTokens.test.ts b/app/components/UI/Bridge/hooks/useFetchPopularTokens.test.ts new file mode 100644 index 00000000000..fcce690cdff --- /dev/null +++ b/app/components/UI/Bridge/hooks/useFetchPopularTokens.test.ts @@ -0,0 +1,248 @@ +import { waitFor } from '@testing-library/react-native'; +import { useFetchPopularTokens } from './useFetchPopularTokens'; +import { createMockPopularToken, MOCK_CHAIN_IDS } from '../testUtils/fixtures'; +import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; +import { initialState } from '../_mocks_/initialState'; +import { popularTokensCache } from '../utils/cacheUtils'; +import type { IncludeAsset } from '../types'; + +let globalFetchSpy: jest.SpyInstance; + +const mockGetBearerToken = jest.fn(); +jest.mock('../../../../core/Engine', () => ({ + context: { + AuthenticationController: { + getBearerToken: () => mockGetBearerToken(), + }, + }, +})); + +const mockPopularTokens = [ + createMockPopularToken({ symbol: 'TEST', name: 'Test Token' }), + createMockPopularToken({ symbol: 'ANOT', name: 'Another Token' }), +]; + +const mockIncludeAsset: IncludeAsset = { + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000123', + decimals: 18, + symbol: 'HELLO', + name: 'Hello', +}; + +describe('useFetchPopularTokens', () => { + beforeEach(() => { + jest.restoreAllMocks(); + mockGetBearerToken.mockClear(); + mockGetBearerToken.mockResolvedValue('mock-bearer-token'); + globalFetchSpy = jest.spyOn(global, 'fetch'); + popularTokensCache.clear(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('returns a stable callback that does not fetch on mount', () => { + const { result } = renderHookWithProvider(() => useFetchPopularTokens(), { + state: initialState, + }); + + expect(typeof result.current).toBe('function'); + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + + it('fetches popular tokens when invoked and caches the result', async () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockPopularTokens, + }); + + const { result } = renderHookWithProvider(() => useFetchPopularTokens(), { + state: initialState, + }); + + const tokens = await result.current({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + includeAssets: [mockIncludeAsset], + }); + + expect(tokens).toStrictEqual(mockPopularTokens); + expect(globalFetchSpy).toHaveBeenCalledTimes(1); + expect(globalFetchSpy).toHaveBeenCalledWith( + expect.stringContaining('/getTokens/popular'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + includeAssets: [mockIncludeAsset], + }), + }), + ); + expect(popularTokensCache.size).toBe(1); + }); + + it('defaults includeAssets to an empty array when omitted', async () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockPopularTokens, + }); + + const { result } = renderHookWithProvider(() => useFetchPopularTokens(), { + state: initialState, + }); + + await result.current({ chainIds: [MOCK_CHAIN_IDS.ethereum] }); + + expect(globalFetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + includeAssets: [], + }), + }), + ); + }); + + it('returns cached data within TTL without re-fetching', async () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => mockPopularTokens, + }); + + const { result } = renderHookWithProvider(() => useFetchPopularTokens(), { + state: initialState, + }); + + await result.current({ chainIds: [MOCK_CHAIN_IDS.ethereum] }); + const cachedTokens = await result.current({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + }); + + expect(cachedTokens).toStrictEqual(mockPopularTokens); + expect(globalFetchSpy).toHaveBeenCalledTimes(1); + }); + + describe('bearer token retrieval on mount', () => { + const bearerTokenOnMountCases = [ + { + description: + 'does not retrieve a bearer token when basic functionality is disabled', + state: { + ...initialState, + settings: { basicFunctionalityEnabled: false }, + }, + assertBearerUsage: async () => { + await waitFor(() => + expect(mockGetBearerToken).not.toHaveBeenCalled(), + ); + }, + }, + { + description: + 'retrieves a bearer token on mount when basic functionality is enabled', + state: initialState, + assertBearerUsage: async () => { + await waitFor(() => expect(mockGetBearerToken).toHaveBeenCalled()); + }, + }, + ]; + + it.each(bearerTokenOnMountCases)( + '$description', + async ({ state, assertBearerUsage }) => { + renderHookWithProvider(() => useFetchPopularTokens(), { state }); + await assertBearerUsage(); + }, + ); + }); + + describe('when the fetch does not yield cacheable popular tokens', () => { + const noCacheUndefinedResultCases = [ + { + description: 'does not cache when the API returns an empty array', + setupFetchMock: () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + }, + }, + { + description: + 'does not cache when the API returns a malformed top-level payload', + setupFetchMock: () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: mockPopularTokens }), + }); + }, + }, + { + description: 'returns undefined when the response is not ok', + setupFetchMock: () => { + globalFetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => mockPopularTokens, + }); + }, + }, + { + description: + 'returns undefined on AbortError without writing to the cache', + setupFetchMock: () => { + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + globalFetchSpy.mockRejectedValueOnce(abortError); + }, + }, + ]; + + it.each(noCacheUndefinedResultCases)( + '$description', + async ({ setupFetchMock }) => { + setupFetchMock(); + + const { result } = renderHookWithProvider( + () => useFetchPopularTokens(), + { + state: initialState, + }, + ); + + const tokens = await result.current({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + }); + + expect(tokens).toBeUndefined(); + expect(popularTokensCache.size).toBe(0); + }, + ); + }); + + it('uses different cache keys for different includeAssets', async () => { + globalFetchSpy + .mockResolvedValueOnce({ ok: true, json: async () => mockPopularTokens }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [mockPopularTokens[0]], + }); + + const { result } = renderHookWithProvider(() => useFetchPopularTokens(), { + state: initialState, + }); + + await result.current({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + includeAssets: [], + }); + await result.current({ + chainIds: [MOCK_CHAIN_IDS.ethereum], + includeAssets: [mockIncludeAsset], + }); + + expect(globalFetchSpy).toHaveBeenCalledTimes(2); + expect(popularTokensCache.size).toBe(2); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useFetchPopularTokens.ts b/app/components/UI/Bridge/hooks/useFetchPopularTokens.ts new file mode 100644 index 00000000000..6a8b0238ace --- /dev/null +++ b/app/components/UI/Bridge/hooks/useFetchPopularTokens.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import type { CaipChainId } from '@metamask/utils'; +import { BridgeClientId, getClientHeaders } from '@metamask/bridge-controller'; + +import { BRIDGE_API_BASE_URL } from '../../../../constants/bridge'; +import Engine from '../../../../core/Engine'; +import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; +import { getBaseSemVerVersion } from '../../../../util/version'; +import type { IncludeAsset, PopularToken } from '../types'; +import { + cleanupExpiredEntries, + getCacheKey, + isCacheValid, + popularTokensCache, + setPopularTokensCache, +} from '../utils/cacheUtils'; + +export interface FetchPopularTokensParams { + chainIds: CaipChainId[]; + includeAssets?: IncludeAsset[]; + signal?: AbortSignal; +} + +/** + * Lightweight fetcher hook for the Bridge `/getTokens/popular` endpoint. + * @returns A callback that performs the cached fetch for the supplied + */ +export const useFetchPopularTokens = () => { + const [bearerToken, setBearerToken] = useState(null); + const isBasicFunctionalityEnabled = useSelector( + selectBasicFunctionalityEnabled, + ); + + useEffect(() => { + if (!isBasicFunctionalityEnabled) { + return; + } + Engine.context.AuthenticationController.getBearerToken() + .then((token) => { + setBearerToken(token); + }) + .catch((error) => { + console.warn( + 'Failed to get bearer token for /getTokens/popular', + error, + ); + }); + }, [isBasicFunctionalityEnabled]); + + return useCallback( + async ({ + chainIds, + includeAssets = [], + signal, + }: FetchPopularTokensParams): Promise => { + cleanupExpiredEntries(); + + const cacheKey = getCacheKey(chainIds, includeAssets); + const cachedEntry = popularTokensCache.get(cacheKey); + if (cachedEntry && isCacheValid(cachedEntry)) { + return cachedEntry.data; + } + + try { + const response = await fetch( + `${BRIDGE_API_BASE_URL}/getTokens/popular`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ + clientId: BridgeClientId.MOBILE, + clientVersion: getBaseSemVerVersion(), + jwt: bearerToken ?? '', + }), + }, + body: JSON.stringify({ chainIds, includeAssets }), + signal, + }, + ); + + if (response.ok === false) { + console.error( + `Failed to fetch popular tokens with status ${response.status}`, + ); + return undefined; + } + + const popularAssetsResponse: PopularToken[] = await response.json(); + const isValidTopLevelPayload = Array.isArray(popularAssetsResponse); + + if (isValidTopLevelPayload && popularAssetsResponse.length > 0) { + // Cache only valid top-level API payloads so malformed responses do + // not suppress retries for the full cache TTL. + setPopularTokensCache({ + includeAssets, + chainIds, + popularTokens: popularAssetsResponse, + }); + return popularAssetsResponse; + } + + return undefined; + } catch (error) { + // Ignore abort errors - request was intentionally cancelled + if (error instanceof Error && error.name === 'AbortError') { + return undefined; + } + console.error('Error fetching popular tokens:', error); + return undefined; + } + }, + [bearerToken], + ); +}; diff --git a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.ts b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.ts index ac009d65d3c..75480a47f9d 100644 --- a/app/components/UI/Bridge/hooks/useInitialBridgeTokens.ts +++ b/app/components/UI/Bridge/hooks/useInitialBridgeTokens.ts @@ -1,23 +1,12 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useMemo, useCallback } from 'react'; import type { CaipChainId } from '@metamask/utils'; -import { BRIDGE_API_BASE_URL } from '../../../../constants/bridge'; -import Engine from '../../../../core/Engine'; +import { useSelector } from 'react-redux'; import { useBalancesByAssetId } from './useBalancesByAssetId'; +import { useFetchPopularTokens } from './useFetchPopularTokens'; import { tokenMatchesQuery, tokenToIncludeAsset } from '../utils/tokenUtils'; -import { getBaseSemVerVersion } from '../../../../util/version'; -import { BridgeClientId, getClientHeaders } from '@metamask/bridge-controller'; -import { useSelector } from 'react-redux'; import { selectAllowedChainRanking } from '../../../../core/redux/slices/bridge'; -import { selectBasicFunctionalityEnabled } from '../../../../selectors/settings'; -import type { IncludeAsset, PopularToken } from '../types'; -import { - cleanupExpiredEntries, - getCacheKey, - getMinimalIncludedAssets, - isCacheValid, - popularTokensCache, - setPopularTokensCache, -} from '../utils/cacheUtils'; +import type { IncludeAsset } from '../types'; +import { getMinimalIncludedAssets } from '../utils/cacheUtils'; /** * Custom hook to fetch popular tokens from the Bridge API with caching @@ -30,12 +19,7 @@ export const useInitialBridgeTokens = ( chainIds?: CaipChainId[], searchString?: string, ) => { - const [bearerToken, setBearerToken] = useState(null); - const enabledChainRanking = useSelector(selectAllowedChainRanking); - const isBasicFunctionalityEnabled = useSelector( - selectBasicFunctionalityEnabled, - ); const chainIdsToFetch = useMemo(() => { if (chainIds) { @@ -76,94 +60,24 @@ export const useInitialBridgeTokens = ( [filteredTokensWithBalance], ); + // Stable string key for the includeAssets array — re-derive the callback + // only when the underlying assetIds change, not when balances flicker. const includeAssetsId = useMemo( () => getMinimalIncludedAssets(includeAssetsObject), [includeAssetsObject], ); - useEffect(() => { - if (isBasicFunctionalityEnabled) { - Engine.context.AuthenticationController.getBearerToken() - .then((token) => { - setBearerToken(token); - }) - .catch((error) => { - console.warn( - 'Failed to get bearer token for /getTokens/popular', - error, - ); - }); - } - }, [isBasicFunctionalityEnabled]); - - const cachedEntry = useMemo(() => { - const cacheKey = getCacheKey(chainIdsToFetch, includeAssetsObject); - return popularTokensCache.get(cacheKey); - }, [chainIdsToFetch, includeAssetsObject]); + const fetchTokens = useFetchPopularTokens(); const fetchPopularTokens = useCallback( - async (signal?: AbortSignal) => { - // Cleanup expired entries before checking cache - cleanupExpiredEntries(); - - // Check if we have a valid cached response - if (cachedEntry && isCacheValid(cachedEntry)) { - return cachedEntry.data; - } - - try { - const response = await fetch( - `${BRIDGE_API_BASE_URL}/getTokens/popular`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...getClientHeaders({ - clientId: BridgeClientId.MOBILE, - clientVersion: getBaseSemVerVersion(), - jwt: bearerToken ?? '', - }), - }, - body: JSON.stringify({ - chainIds: chainIdsToFetch, - includeAssets: includeAssetsObject, - }), - signal, - }, - ); - - if (response.ok === false) { - console.error( - `Failed to fetch popular tokens with status ${response.status}`, - ); - return undefined; - } - - const popularAssetsResponse: PopularToken[] = await response.json(); - const isValidTopLevelPayload = Array.isArray(popularAssetsResponse); - - if (isValidTopLevelPayload && popularAssetsResponse.length > 0) { - // Cache only valid top-level API payloads so malformed responses do - // not suppress retries for the full cache TTL. - setPopularTokensCache({ - includeAssets: includeAssetsObject, - chainIds: chainIdsToFetch, - popularTokens: popularAssetsResponse, - }); - return popularAssetsResponse; - } - - return undefined; - } catch (error) { - // Ignore abort errors - request was intentionally cancelled - if (error instanceof Error && error.name === 'AbortError') { - return; - } - console.error('Error fetching popular tokens:', error); - } - }, + (signal?: AbortSignal) => + fetchTokens({ + chainIds: chainIdsToFetch, + includeAssets: includeAssetsObject, + signal, + }), // eslint-disable-next-line react-hooks/exhaustive-deps - [includeAssetsId, chainIdsToFetch, bearerToken, cachedEntry], + [includeAssetsId, chainIdsToFetch, fetchTokens], ); const searchQuery = searchString?.trim(); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts index 8bded865c15..3b29a795a4d 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts @@ -22,6 +22,7 @@ import { } from '../../../../../util/analytics/actionButtonTracking'; import { useAddNetwork } from '../../../../hooks/useAddNetwork'; import { + selectAllowedChainRanking, selectIsBridgeEnabledSourceFactory, setSourceToken, setDestToken, @@ -40,7 +41,7 @@ import { import { areAddressesEqual } from '../../../../../util/address'; import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings'; import TrendingFeedSessionManager from '../../../Trending/services/TrendingFeedSessionManager'; -import { useInitialBridgeTokens } from '../useInitialBridgeTokens'; +import { useFetchPopularTokens } from '../useFetchPopularTokens'; /** * When navigating to the Asset view from trending tokens list, we add a property @@ -158,7 +159,20 @@ export const useSwapBridgeNavigation = ({ const isBasicFunctionalityEnabled = useSelector( selectBasicFunctionalityEnabled, ); - const { fetchPopularTokens } = useInitialBridgeTokens(); + + const enabledChainRanking = useSelector(selectAllowedChainRanking); + + const fetchPopularTokens = useFetchPopularTokens(); + const prefetchPopularTokens = useCallback(() => { + if (!enabledChainRanking?.length) { + return; + } + fetchPopularTokens({ + chainIds: enabledChainRanking.map( + (chain: { chainId: CaipChainId }) => chain.chainId, + ), + }).catch(() => undefined); + }, [enabledChainRanking, fetchPopularTokens]); // Unified swaps/bridge UI const goToNativeBridge = useCallback( @@ -307,7 +321,7 @@ export const useSwapBridgeNavigation = ({ // Prefetch popular tokens if (isBasicFunctionalityEnabled) { - fetchPopularTokens(); + prefetchPopularTokens(); } // Navigate before Redux bridge updates so the Wallet tab does not repaint from slice // dispatches while still visible (e.g. checklist trade primary → swaps). @@ -377,7 +391,7 @@ export const useSwapBridgeNavigation = ({ swapButtonEventLocationOverride, transactionActiveAbTests, isBasicFunctionalityEnabled, - fetchPopularTokens, + prefetchPopularTokens, ], ); const { networkModal } = useAddNetwork(); diff --git a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts index 03c2346ae81..b441eb73630 100644 --- a/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts +++ b/app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts @@ -128,11 +128,13 @@ jest.mock('../../utils/tokenUtils', () => ({ getNativeSourceToken: jest.fn(), })); -const mockFetchPopularTokens = jest.fn(); -jest.mock('../useInitialBridgeTokens', () => ({ - useInitialBridgeTokens: jest.fn().mockReturnValue({ - fetchPopularTokens: () => mockFetchPopularTokens(), - }), +const mockFetchPopularTokens = jest.fn().mockResolvedValue(undefined); +jest.mock('../useFetchPopularTokens', () => ({ + useFetchPopularTokens: jest.fn( + () => + (...args: unknown[]) => + mockFetchPopularTokens(...args), + ), })); describe('useSwapBridgeNavigation', () => { diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx index 37a6e68a2ee..895372ea186 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx @@ -83,6 +83,11 @@ jest.mock('../hooks/useTokenActions', () => ({ useTokenActions: () => mockUseTokenActions(), })); +const mockUseStickyTokenActions = jest.fn(); +jest.mock('../hooks/useStickyTokenActions', () => ({ + useStickyTokenActions: () => mockUseStickyTokenActions(), +})); + const defaultUseTokenTransactionsReturn = { transactions: [], submittedTxs: [], @@ -263,7 +268,10 @@ describe('TokenDetails', () => { onBuy: mockOnBuy, onSend: jest.fn(), onReceive: jest.fn(), - handleStickySwapPress: mockHandleStickySwapPress, + }); + mockUseStickyTokenActions.mockReturnValue({ + onBuy: mockOnBuy, + onSwap: mockHandleStickySwapPress, hasEligibleSwapTokens: true, networkModal: null, }); @@ -348,11 +356,9 @@ describe('TokenDetails', () => { }); it('shows only Buy when user has no eligible swap tokens', () => { - mockUseTokenActions.mockReturnValue({ + mockUseStickyTokenActions.mockReturnValue({ onBuy: mockOnBuy, - onSend: jest.fn(), - onReceive: jest.fn(), - handleStickySwapPress: mockHandleStickySwapPress, + onSwap: mockHandleStickySwapPress, hasEligibleSwapTokens: false, networkModal: null, }); diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 051596f3727..127eb46d56e 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -197,7 +197,6 @@ const TokenDetails: React.FC<{ const { onBuy, onSend, onReceive } = useTokenActions({ token, networkName, - currentTokenBalance: balance, }); const { diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx index 5c9c17fb72d..7b7e227b681 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.test.tsx @@ -59,12 +59,10 @@ jest.mock('../../../../util/theme', () => { const mockOnBuy = jest.fn(); const mockOnSwap = jest.fn(); let mockHasEligibleSwapTokens = true; -jest.mock('../hooks/useTokenActions', () => ({ - useTokenActions: () => ({ +jest.mock('../hooks/useStickyTokenActions', () => ({ + useStickyTokenActions: () => ({ onBuy: mockOnBuy, - onSend: jest.fn(), - onReceive: jest.fn(), - handleStickySwapPress: mockOnSwap, + onSwap: mockOnSwap, hasEligibleSwapTokens: mockHasEligibleSwapTokens, networkModal: null, }), diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx index 7c26ba3a1e8..bb12bb89fa6 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx @@ -30,7 +30,7 @@ import { ONDO_RESTRICTED_COUNTRIES } from '../../../../util/ondoGeoRestrictions' import RwaUnavailableBottomSheet, { type RwaUnavailableBottomSheetRef, } from './RwaUnavailableBottomSheet/RwaUnavailableBottomSheet'; -import { useTokenActions } from '../hooks/useTokenActions'; +import { useStickyTokenActions } from '../hooks/useStickyTokenActions'; import { getResultTypeConfig } from '../../SecurityTrust/utils/securityUtils'; const styles = StyleSheet.create({ @@ -115,17 +115,12 @@ const TokenDetailsStickyFooter: React.FC = ({ [successText], ); - const { - onBuy, - handleStickySwapPress: onSwap, - hasEligibleSwapTokens, - networkModal, - } = useTokenActions({ - token, - networkName, - currentTokenBalance, - sourcePage, - }); + const { onBuy, onSwap, hasEligibleSwapTokens, networkModal } = + useStickyTokenActions({ + token, + currentTokenBalance, + sourcePage, + }); const { isBuyable } = useTokenBuyability(token); const { isTokenTradingOpen, isStockToken } = useRWAToken(); diff --git a/app/components/UI/TokenDetails/hooks/useStickyTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useStickyTokenActions.test.ts new file mode 100644 index 00000000000..050f131faf7 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useStickyTokenActions.test.ts @@ -0,0 +1,75 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { useStickyTokenActions } from './useStickyTokenActions'; +import { useHandleOnBuy, useHandleOnSwap } from './useTokenAtomicActions'; +import { useAddNetwork } from '../../../hooks/useAddNetwork'; +import { TokenI } from '../../Tokens/types'; + +const mockOnBuy = jest.fn(); +const mockOnSwap = jest.fn(); + +jest.mock('./useTokenAtomicActions', () => ({ + useHandleOnBuy: jest.fn(), + useHandleOnSwap: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../../hooks/useAddNetwork', () => ({ + useAddNetwork: jest.fn(), +})); + +const mockUseHandleOnBuy = jest.mocked(useHandleOnBuy); +const mockUseHandleOnSwap = jest.mocked(useHandleOnSwap); +const mockUseAddNetwork = jest.mocked(useAddNetwork); +const mockUseSelector = jest.mocked(useSelector); + +// useStickyTokenActions is a thin wrapper around the atomic hooks (plus swap eligibility + network UI). +// More in-depth tests are in the atomic action tests. +describe('useStickyTokenActions', () => { + const defaultToken: TokenI = { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: '0x1', + symbol: 'DAI', + decimals: 18, + name: 'Dai Stablecoin', + image: 'https://example.com/dai.png', + isETH: false, + isNative: false, + } as TokenI; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseHandleOnBuy.mockReturnValue(mockOnBuy); + mockUseHandleOnSwap.mockReturnValue(mockOnSwap); + mockUseAddNetwork.mockReturnValue({ + networkModal: null, + } as unknown as ReturnType); + mockUseSelector.mockReturnValue(true); + }); + + it('returns sticky-footer handlers, swap eligibility, and network modal from composed hooks', () => { + const { result } = renderHook(() => + useStickyTokenActions({ + token: defaultToken, + currentTokenBalance: '1.25', + sourcePage: 'TokenDetails', + }), + ); + + expect(result.current.onBuy).toBe(mockOnBuy); + expect(result.current.onSwap).toBe(mockOnSwap); + expect(result.current.hasEligibleSwapTokens).toBe(true); + expect(result.current.networkModal).toBeNull(); + + expect(mockUseHandleOnBuy).toHaveBeenCalledWith({ token: defaultToken }); + expect(mockUseHandleOnSwap).toHaveBeenCalledWith({ + token: defaultToken, + currentTokenBalance: '1.25', + sourcePage: 'TokenDetails', + }); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useStickyTokenActions.ts b/app/components/UI/TokenDetails/hooks/useStickyTokenActions.ts new file mode 100644 index 00000000000..aa4eb12f873 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useStickyTokenActions.ts @@ -0,0 +1,42 @@ +import { useSelector } from 'react-redux'; +import { useAddNetwork } from '../../../hooks/useAddNetwork'; +import { selectHasEligibleSwapSource } from '../../../../selectors/assets/assets-list'; +import type { RootState } from '../../../../reducers'; +import { + TokenActionInput, + useHandleOnBuy, + useHandleOnSwap, +} from './useTokenAtomicActions'; + +/** + * Composed hook for the Token Details sticky footer. + */ +export const useStickyTokenActions = ({ + token, + currentTokenBalance, + sourcePage = 'MainView', +}: { + token: TokenActionInput; + /** Optional up-to-date token balance from Token Details balance hook */ + currentTokenBalance?: string; + /** Page name sent with swap/bridge analytics. Defaults to `'MainView'`. */ + sourcePage?: string; +}) => { + const hasEligibleSwapTokens = useSelector((state: RootState) => + selectHasEligibleSwapSource(state, token.chainId, token.address), + ); + + const onBuy = useHandleOnBuy({ token }); + const onSwap = useHandleOnSwap({ token, currentTokenBalance, sourcePage }); + + const { networkModal } = useAddNetwork(); + + return { + onBuy, + onSwap, + hasEligibleSwapTokens, + networkModal, + }; +}; + +export default useStickyTokenActions; diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index f305aef39d6..82112f14246 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts @@ -1,182 +1,29 @@ import { renderHook } from '@testing-library/react-native'; -import { TokenSecurityData } from '@metamask/assets-controllers'; -import { useSelector } from 'react-redux'; -import { useTokenActions, getSwapTokens } from './useTokenActions'; -import { TokenI } from '../../Tokens/types'; -import { SecurityDataType } from '../../Bridge/types'; -import { selectEvmChainId } from '../../../../selectors/networkController'; -import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; -import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; -import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; -import { selectAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useTokenActions } from './useTokenActions'; import { - ActionButtonType, - ActionPosition, - ActionLocation, -} from '../../../../util/analytics/actionButtonTracking'; -import Routes from '../../../../constants/navigation/Routes'; -import { isCaipAssetType } from '@metamask/utils'; -import { formatAddressToAssetId } from '@metamask/bridge-controller'; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -const mockNavigate = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockNavigate, - }), -})); - -jest.mock('../../../../selectors/networkController', () => ({ - ...jest.requireActual< - typeof import('../../../../selectors/networkController') - >('../../../../selectors/networkController'), - selectEvmChainId: jest.fn(), -})); - -jest.mock('../../../../selectors/accountsController', () => ({ - ...jest.requireActual< - typeof import('../../../../selectors/accountsController') - >('../../../../selectors/accountsController'), - selectSelectedInternalAccount: jest.fn(), -})); - -jest.mock( - '../../../../selectors/multichainAccounts/accountTreeController', - () => ({ - selectSelectedAccountGroup: jest.fn(), - }), -); - -jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ - selectSelectedInternalAccountByScope: jest.fn(), -})); - -jest.mock('../../../../reducers/fiatOrders', () => ({ - getDetectedGeolocation: jest.fn(), -})); - -jest.mock('../../../../selectors/assets/assets-list', () => ({ - selectAssetsBySelectedAccountGroup: jest.fn(), -})); - -const mockTrackEvent = jest.fn(); -const mockAddProperties = jest.fn().mockReturnThis(); -const mockBuild = jest.fn().mockReturnValue({}); -const createMockEventBuilder = () => ({ - addProperties: mockAddProperties, - build: mockBuild, -}); -const mockCreateEventBuilder = jest.fn(() => createMockEventBuilder()); - -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), -})); - -const mockNavigateToSendPage = jest.fn(); -jest.mock('../../../Views/confirmations/hooks/useSendNavigation', () => ({ - useSendNavigation: () => ({ - navigateToSendPage: mockNavigateToSendPage, - }), -})); - -const mockGoToBuy = jest.fn(); -jest.mock('../../Ramp/hooks/useRampNavigation', () => ({ - useRampNavigation: () => ({ - goToBuy: mockGoToBuy, - }), -})); - -jest.mock('../../Ramp/hooks/useRampsButtonClickData', () => ({ - useRampsButtonClickData: () => ({ - ramp_routing: 'test-routing', - is_authenticated: true, - preferred_provider: 'test-provider', - order_count: 0, - }), -})); - -jest.mock('../../Ramp/hooks/useRampsUnifiedV1Enabled', () => - jest.fn(() => false), -); - -jest.mock('../../../hooks/useSendNonEvmAsset', () => ({ - useSendNonEvmAsset: () => ({ - sendNonEvmAsset: jest.fn().mockResolvedValue(false), - }), -})); - -const mockGoToSwaps = jest.fn(); -const mockNetworkModal = null; -jest.mock('../../Bridge/hooks/useSwapBridgeNavigation', () => ({ - useSwapBridgeNavigation: () => ({ - goToSwaps: mockGoToSwaps, - networkModal: mockNetworkModal, - }), - SwapBridgeNavigationLocation: { - TokenView: 'TokenView', - }, - isAssetFromTrending: jest.fn(() => false), -})); - -jest.mock('../../Bridge/utils/tokenUtils', () => ({ - getDefaultDestToken: jest.fn(), - getNativeSourceToken: jest.fn(), -})); - -jest.mock('@metamask/utils', () => ({ - ...jest.requireActual('@metamask/utils'), - isCaipAssetType: jest.fn(), -})); - -jest.mock('@metamask/bridge-controller', () => ({ - ...jest.requireActual('@metamask/bridge-controller'), - formatAddressToAssetId: jest.fn(), -})); + useHandleOnBuy, + useHandleOnReceive, + useHandleOnSend, +} from './useTokenAtomicActions'; +import { TokenI } from '../../Tokens/types'; -const mockIsCaipAssetType = jest.mocked(isCaipAssetType); -const mockFormatAddressToAssetId = jest.mocked(formatAddressToAssetId); +const mockOnBuy = jest.fn(); +const mockOnSend = jest.fn(); +const mockOnReceive = jest.fn(); -jest.mock('../../../../core/Engine', () => ({ - context: { - NetworkController: { - getNetworkConfigurationByChainId: jest.fn(() => ({ - rpcEndpoints: [{ networkClientId: 'mainnet' }], - defaultRpcEndpointIndex: 0, - })), - }, - MultichainNetworkController: { - setActiveNetwork: jest.fn(), - }, - }, +jest.mock('./useTokenAtomicActions', () => ({ + useHandleOnBuy: jest.fn(), + useHandleOnSend: jest.fn(), + useHandleOnReceive: jest.fn(), })); -const mockUseSelector = jest.mocked(useSelector); +const mockUseHandleOnBuy = jest.mocked(useHandleOnBuy); +const mockUseHandleOnSend = jest.mocked(useHandleOnSend); +const mockUseHandleOnReceive = jest.mocked(useHandleOnReceive); +// useTokenActions is a thin wrapper around the atomic hooks. +// More in-depth tests are in the atomic action tests. describe('useTokenActions', () => { - const mockAccountAddress = '0x1234567890abcdef1234567890abcdef12345678'; - - const mockAccount = { - id: 'account-1', - address: mockAccountAddress, - metadata: { name: 'Account 1', keyring: { type: 'HD Key Tree' } }, - type: 'eip155:eoa', - }; - - const mockAccountGroup = { - id: 'group-1', - name: 'Test Group', - }; - const defaultToken: TokenI = { address: '0x6b175474e89094c44da98b954eedeac495271d0f', chainId: '0x1', @@ -188,663 +35,31 @@ describe('useTokenActions', () => { isNative: false, } as TokenI; - /** - * Sets up default selector mocks and returns individual mock functions - * that can be overridden in specific tests. - * - * @example - * // Override a specific selector in a test: - * const mocks = setupDefaultMocks(); - * mocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue({ '0x1': [...] }); - */ - const setupDefaultMocks = () => { - const mockSelectEvmChainId = jest.fn().mockReturnValue('0x1'); - const mockSelectSelectedInternalAccount = jest - .fn() - .mockReturnValue(mockAccount); - const mockSelectSelectedAccountGroup = jest - .fn() - .mockReturnValue(mockAccountGroup); - const mockSelectSelectedInternalAccountByScope = jest - .fn() - .mockReturnValue(() => mockAccount); - const mockGetDetectedGeolocation = jest.fn().mockReturnValue('US'); - const mockSelectAssetsBySelectedAccountGroup = jest - .fn() - .mockReturnValue({}); // Empty object of user assets (keyed by chainId) - - mockUseSelector.mockImplementation((selector) => { - if (selector === selectEvmChainId) { - return mockSelectEvmChainId(); - } - if (selector === selectSelectedInternalAccount) { - return mockSelectSelectedInternalAccount(); - } - if (selector === selectSelectedAccountGroup) { - return mockSelectSelectedAccountGroup(); - } - if (selector === selectSelectedInternalAccountByScope) { - return mockSelectSelectedInternalAccountByScope(); - } - if (selector === getDetectedGeolocation) { - return mockGetDetectedGeolocation(); - } - if (selector === selectAssetsBySelectedAccountGroup) { - return mockSelectAssetsBySelectedAccountGroup(); - } - if (typeof selector === 'function') { - return 'ETH'; - } - return undefined; - }); - - return { - mockSelectEvmChainId, - mockSelectSelectedInternalAccount, - mockSelectSelectedAccountGroup, - mockSelectSelectedInternalAccountByScope, - mockGetDetectedGeolocation, - mockSelectAssetsBySelectedAccountGroup, - }; - }; - - // Store mocks returned from setupDefaultMocks for per-test overrides - let selectorMocks: ReturnType; - beforeEach(() => { jest.clearAllMocks(); - selectorMocks = setupDefaultMocks(); + mockUseHandleOnBuy.mockReturnValue(mockOnBuy); + mockUseHandleOnSend.mockReturnValue(mockOnSend); + mockUseHandleOnReceive.mockReturnValue(mockOnReceive); }); - describe('getSwapTokens', () => { - it('returns sourceToken as the token and destToken as undefined for regular tokens', () => { - const result = getSwapTokens(defaultToken); - - expect(result.sourceToken).toMatchObject({ - address: defaultToken.address, - chainId: defaultToken.chainId, - symbol: defaultToken.symbol, - }); - expect(result.destToken).toBeUndefined(); - }); - }); - - describe('hook return value', () => { - it('returns all action handlers and networkModal', () => { - const { result } = renderHook(() => - useTokenActions({ - token: defaultToken, - networkName: 'Ethereum Mainnet', - }), - ); - - expect(result.current).toHaveProperty('onBuy'); - expect(result.current).toHaveProperty('onSend'); - expect(result.current).toHaveProperty('onReceive'); - expect(result.current).toHaveProperty('handleStickySwapPress'); - expect(result.current).toHaveProperty('networkModal'); - - expect(typeof result.current.onBuy).toBe('function'); - expect(typeof result.current.onSend).toBe('function'); - expect(typeof result.current.onReceive).toBe('function'); - expect(typeof result.current.handleStickySwapPress).toBe('function'); - }); - }); - - describe('onBuy', () => { - it('calls goToBuy with parsed assetId and tracks analytics', () => { - const { result } = renderHook(() => - useTokenActions({ - token: defaultToken, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.onBuy(); - - const expectedAssetId = - 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; - expect(mockGoToBuy).toHaveBeenCalledTimes(1); - expect(mockGoToBuy).toHaveBeenCalledWith( - { assetId: expectedAssetId }, - { buyFlowOrigin: 'tokenInfo' }, - ); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.ACTION_BUTTON_CLICKED, - ); - - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - action_name: ActionButtonType.BUY, - action_position: ActionPosition.FIRST_POSITION, - location: ActionLocation.ASSET_DETAILS, - }), - ); - - expect(mockTrackEvent).toHaveBeenCalledTimes(2); - }); - - it('includes asset_symbol in RAMPS_BUTTON_CLICKED event', () => { - const { result } = renderHook(() => - useTokenActions({ - token: defaultToken, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.onBuy(); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.RAMPS_BUTTON_CLICKED, - ); - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - location: 'TokenDetails', - asset_symbol: 'DAI', - }), - ); - }); - }); - - describe('onSend', () => { - it('navigates to send page and tracks analytics', async () => { - const { result } = renderHook(() => - useTokenActions({ - token: defaultToken, - networkName: 'Ethereum Mainnet', - }), - ); - - await result.current.onSend(); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.ACTION_BUTTON_CLICKED, - ); - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - action_name: ActionButtonType.SEND, - action_position: ActionPosition.THIRD_POSITION, - location: ActionLocation.ASSET_DETAILS, - }), - ); - - expect(mockNavigateToSendPage).toHaveBeenCalledWith({ - location: 'asset_overview', - asset: defaultToken, - }); - }); - }); - - describe('onReceive', () => { - it('navigates to share address QR and tracks analytics', () => { - const { result } = renderHook(() => - useTokenActions({ - token: defaultToken, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.onReceive(); - - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.ACTION_BUTTON_CLICKED, - ); - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - action_name: ActionButtonType.RECEIVE, - action_position: ActionPosition.FOURTH_POSITION, - location: ActionLocation.ASSET_DETAILS, - }), - ); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, - { - screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.SHARE_ADDRESS_QR, - params: { - address: mockAccountAddress, - networkName: 'Ethereum Mainnet', - chainId: '0x1', - groupId: 'group-1', - }, - }, - ); - }); - }); - - /** - * Swap entry from Token Details sticky CTA (`handleStickySwapPress`): - * - Has Balance: - * -- from: current token - * -- to: undefined (swap UI picks default dest -- e.g. mUSD / last used) - * - * - No Balance: - * -- from: `buySourceToken` (best available) - * -- to: current token - * - * `buySourceToken` priority: - * 1. Same chain token (not current) with highest fiat balance - * 2. Native token (ETH, POL, etc.) on any chain with highest fiat balance - * 3. Last swapped token (Not supported — needs data source) - * 4. Most used token (Not supported — needs data source) - * 5. Fallback: any token on any chain with highest fiat balance - */ - describe('handleStickySwapPress', () => { - const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; - const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; - const POLYGON_USDC_ADDRESS = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'; - - interface StickySwapUserAsset { - assetId: string; - chainId: string; - decimals: number; - symbol: string; - name: string; - image: string; - isNative?: boolean; - fiat?: { balance: number }; - } - - const arrangeToken = (balance: string): TokenI => - ({ ...defaultToken, balance }) as TokenI; - - /** Mirrors `selectAssetsBySelectedAccountGroup` shape (values flattened in hook). */ - const arrangeUserAssets = ( - assetsByChain: Record = {}, - ) => assetsByChain; - - const userAsset = (params: { - assetId: string; - chainId?: string; - symbol: string; - name?: string; - decimals?: number; - fiatBalance?: number; - isNative?: boolean; - }): StickySwapUserAsset => ({ - assetId: params.assetId, - chainId: params.chainId ?? '0x1', - decimals: params.decimals ?? 18, - symbol: params.symbol, - name: params.name ?? params.symbol, - image: '', - isNative: params.isNative ?? false, - ...(params.fiatBalance !== undefined - ? { fiat: { balance: params.fiatBalance } } - : {}), - }); - - const hasBalanceCases = [ - { - name: 'from current token, to default (undefined dest for swap UI)', - token: arrangeToken('1'), - userAssets: arrangeUserAssets(), - expectedDestinationAddress: undefined, - }, - { - name: 'currentTokenBalance overrides token.balance when positive', - token: arrangeToken('0'), - currentTokenBalance: '0.5', - userAssets: arrangeUserAssets({ - '0x1': [ - userAsset({ - assetId: WETH_ADDRESS, - symbol: 'WETH', - fiatBalance: 9000, - }), - ], - }), - expectedDestinationAddress: undefined, - }, - ]; - - it.each(hasBalanceCases)( - 'has balance — $name', - ({ - token, - currentTokenBalance, - userAssets, - expectedDestinationAddress, - }) => { - selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue( - userAssets, - ); - - const { result } = renderHook(() => - useTokenActions({ - token, - networkName: 'Ethereum Mainnet', - ...(currentTokenBalance !== undefined && { currentTokenBalance }), - }), - ); - - result.current.handleStickySwapPress(); - - expect(mockGoToSwaps).toHaveBeenCalledTimes(1); - expect(mockGoToSwaps).toHaveBeenCalledWith( - expect.objectContaining({ address: defaultToken.address }), - expectedDestinationAddress !== undefined - ? expect.objectContaining({ address: expectedDestinationAddress }) - : undefined, - undefined, - true, - ); - }, - ); - - const noBalanceCases = [ - { - name: 'Priority 1: same chain: best token by fiat to current token', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x1': [ - userAsset({ - assetId: WETH_ADDRESS, - symbol: 'WETH', - fiatBalance: 1000, - }), - userAsset({ - assetId: USDC_ADDRESS, - symbol: 'USDC', - decimals: 6, - fiatBalance: 5000, - }), - ], - }), - expectedSourceAddress: USDC_ADDRESS, // USDC has higher fiat balance than WETH - expectedDestinationAddress: defaultToken.address, - }, - { - name: 'Priority 1: same chain: excludes current asset on same chain; next-best same-chain wins', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x1': [ - userAsset({ - assetId: defaultToken.address, - symbol: defaultToken.symbol, - fiatBalance: 9999, - }), - userAsset({ - assetId: WETH_ADDRESS, - symbol: 'WETH', - fiatBalance: 100, - }), - ], - }), - expectedSourceAddress: WETH_ADDRESS, - expectedDestinationAddress: defaultToken.address, - }, - { - name: 'Priority 2: cross chain: native token with highest fiat', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x89': [ - userAsset({ - assetId: POLYGON_USDC_ADDRESS, - chainId: '0x89', - symbol: 'USDC', - decimals: 6, - fiatBalance: 5000, - }), - userAsset({ - assetId: '0x0000000000000000000000000000000000001010', - chainId: '0x89', - symbol: 'POL', - name: 'POL', - decimals: 18, - fiatBalance: 200, - isNative: true, - }), - ], - }), - expectedSourceAddress: '0x0000000000000000000000000000000000001010', // cross chain swap, we prefer the native token - expectedDestinationAddress: defaultToken.address, - }, - { - name: 'Priority 2: cross chain: picks native token with highest fiat among multiple native tokens', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x89': [ - userAsset({ - assetId: '0x0000000000000000000000000000000000001010', - chainId: '0x89', - symbol: 'POL', - name: 'POL', - decimals: 18, - fiatBalance: 200, - isNative: true, - }), - ], - '0xa': [ - userAsset({ - assetId: '0x0000000000000000000000000000000000000000', - chainId: '0xa', - symbol: 'ETH', - name: 'Ethereum', - decimals: 18, - fiatBalance: 3000, - isNative: true, - }), - ], - }), - expectedSourceAddress: '0x0000000000000000000000000000000000000000', // 0xa native token has the highest native balance - expectedDestinationAddress: defaultToken.address, - }, - { - name: 'Priority 2: no native tokens available: falls back to highest fiat non-native cross-chain token', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x89': [ - userAsset({ - assetId: POLYGON_USDC_ADDRESS, - chainId: '0x89', - symbol: 'USDC', - decimals: 6, - fiatBalance: 800, - }), - ], - }), - expectedSourceAddress: POLYGON_USDC_ADDRESS, - expectedDestinationAddress: defaultToken.address, - }, - - { - name: 'Edge case: no eligible source: only current token with fiat — falls back to current, undefined dest', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x1': [ - userAsset({ - assetId: defaultToken.address, - symbol: defaultToken.symbol, - fiatBalance: 100, - }), - ], - }), - expectedSourceAddress: defaultToken.address, - expectedDestinationAddress: undefined, - }, - { - name: 'Edge case: no eligible source: other tokens have zero or missing fiat — falls back to current, undefined dest', - token: arrangeToken('0'), - userAssets: arrangeUserAssets({ - '0x1': [ - userAsset({ - assetId: WETH_ADDRESS, - symbol: 'WETH', - fiatBalance: 0, - }), - userAsset({ - assetId: USDC_ADDRESS, - symbol: 'USDC', - decimals: 6, - }), - ], - }), - expectedSourceAddress: defaultToken.address, - expectedDestinationAddress: undefined, - }, - ]; - - it.each(noBalanceCases)( - 'no balance — $name', - ({ - token, - userAssets, - expectedSourceAddress, - expectedDestinationAddress, - }) => { - selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue( - userAssets, - ); - - const { result } = renderHook(() => - useTokenActions({ - token, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.handleStickySwapPress(); - - expect(mockGoToSwaps).toHaveBeenCalledTimes(1); - expect(mockGoToSwaps).toHaveBeenCalledWith( - expect.objectContaining({ address: expectedSourceAddress }), - expectedDestinationAddress !== undefined - ? expect.objectContaining({ address: expectedDestinationAddress }) - : undefined, - undefined, - true, - ); - }, + it('returns the action handlers from the atomic hooks', () => { + const { result } = renderHook(() => + useTokenActions({ + token: defaultToken, + networkName: 'Ethereum Mainnet', + }), ); - describe('securityData adaptation', () => { - const buildTrendingSecurityData = ( - overrides: Partial = {}, - ): TokenSecurityData => ({ - resultType: 'Warning', - maliciousScore: '50', - fees: { transfer: 0, transferFeeMaxAmount: null, buy: 0, sell: null }, - features: [ - { - featureId: 'HONEYPOT', - type: 'Warning', - description: 'Honeypot risk', - }, - ], - financialStats: { - supply: 0, - topHolders: [], - holdersCount: 0, - tradeVolume24h: null, - lockedLiquidityPct: null, - markets: [], - }, - metadata: { - externalLinks: { - homepage: null, - twitterPage: null, - telegramChannelId: null, - }, - }, - created: '2025-01-01T00:00:00Z', - ...overrides, - }); - - it("adapts trending-shape securityData to the bridge's legacy shape when handing off to goToSwaps", () => { - const tokenWithSecurity: TokenI = { - ...defaultToken, - balance: '1', - securityData: buildTrendingSecurityData(), - } as TokenI; - - const { result } = renderHook(() => - useTokenActions({ - token: tokenWithSecurity, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.handleStickySwapPress(); - - expect(mockGoToSwaps).toHaveBeenCalledTimes(1); - expect(mockGoToSwaps).toHaveBeenCalledWith( - expect.objectContaining({ - address: defaultToken.address, - securityData: { - type: SecurityDataType.Warning, - metadata: { - features: [ - { - featureId: 'HONEYPOT', - type: SecurityDataType.Warning, - description: 'Honeypot risk', - }, - ], - }, - }, - }), - undefined, - undefined, - true, - ); - }); - - it('passes securityData as undefined when token has no security data', () => { - const tokenWithBalance: TokenI = { - ...defaultToken, - balance: '1', - } as TokenI; - - const { result } = renderHook(() => - useTokenActions({ - token: tokenWithBalance, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.handleStickySwapPress(); - - expect(mockGoToSwaps).toHaveBeenCalledTimes(1); - expect(mockGoToSwaps).toHaveBeenCalledWith( - expect.objectContaining({ - address: defaultToken.address, - securityData: undefined, - }), - undefined, - undefined, - true, - ); - }); - - it('forwards rwaData to goToSwaps so selectIsRwaSwap works for the convert flow', () => { - const rwaToken: TokenI = { - ...defaultToken, - balance: '1', - rwaData: { instrumentType: 'stock' } as TokenI['rwaData'], - } as TokenI; - - const { result } = renderHook(() => - useTokenActions({ - token: rwaToken, - networkName: 'Ethereum Mainnet', - }), - ); - - result.current.handleStickySwapPress(); + expect(result.current.onBuy).toBe(mockOnBuy); + expect(result.current.onSend).toBe(mockOnSend); + expect(result.current.onReceive).toBe(mockOnReceive); - expect(mockGoToSwaps).toHaveBeenCalledTimes(1); - expect(mockGoToSwaps).toHaveBeenCalledWith( - expect.objectContaining({ - address: defaultToken.address, - rwaData: { instrumentType: 'stock' }, - }), - undefined, - undefined, - true, - ); - }); + // make sure that each hook handler passed the token + expect(mockUseHandleOnBuy).toHaveBeenCalledWith({ token: defaultToken }); + expect(mockUseHandleOnSend).toHaveBeenCalledWith({ token: defaultToken }); + expect(mockUseHandleOnReceive).toHaveBeenCalledWith({ + token: defaultToken, + networkName: 'Ethereum Mainnet', }); }); }); diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 987bf1ab5eb..c8db7b5e9f9 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -1,487 +1,25 @@ -import { useCallback, useMemo } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; -import { Hex, CaipChainId, isCaipAssetType } from '@metamask/utils'; -import { strings } from '../../../../../locales/i18n'; -import Engine from '../../../../core/Engine'; -import { selectEvmChainId } from '../../../../selectors/networkController'; -import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; -import Logger from '../../../../util/Logger'; -import Routes from '../../../../constants/navigation/Routes'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { getDecimalChainId } from '../../../../util/networks'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { - trackActionButtonClick, - ActionButtonType, - ActionLocation, - ActionPosition, -} from '../../../../util/analytics/actionButtonTracking'; -import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; -import { selectAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; -import { areAddressesEqual } from '../../../../util/address'; -import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; -import { TokenI } from '../../Tokens/types'; -import { - useSwapBridgeNavigation, - SwapBridgeNavigationLocation, - isAssetFromTrending, -} from '../../Bridge/hooks/useSwapBridgeNavigation'; -import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../../constants/bridge'; -import { - getNativeSourceToken, - getDefaultDestToken, -} from '../../Bridge/utils/tokenUtils'; -import { useSendNonEvmAsset } from '../../../hooks/useSendNonEvmAsset'; -import { - formatChainIdToCaip, - isNativeAddress, -} from '@metamask/bridge-controller'; -import { InitSendLocation } from '../../../Views/confirmations/constants/send'; -import { useSendNavigation } from '../../../Views/confirmations/hooks/useSendNavigation'; -import parseRampIntent from '../../Ramp/utils/parseRampIntent'; -import { getDetectedGeolocation } from '../../../../reducers/fiatOrders'; -import { useRampsButtonClickData } from '../../Ramp/hooks/useRampsButtonClickData'; -import useRampsUnifiedV1Enabled from '../../Ramp/hooks/useRampsUnifiedV1Enabled'; -import { BridgeToken } from '../../Bridge/types'; -import { adaptTokenSecurityData } from '../../Bridge/utils/tokenSecurityUtils'; -import { TokenDetailsSource } from '../constants/constants'; -import type { TransactionActiveAbTestEntry } from '../../../../util/transactions/transaction-active-ab-test-attribution-registry'; - -/** - * Determines the source and destination tokens for swap/bridge navigation. - */ -export const getSwapTokens = ( - token: TokenI, -): { - sourceToken: BridgeToken | undefined; - destToken: BridgeToken | undefined; -} => { - const wantsToBuyToken = isAssetFromTrending(token); - const isNative = isNativeAddress(token.address); - - const bridgeToken: BridgeToken = { - ...token, - address: token.address ?? NATIVE_SWAPS_TOKEN_ADDRESS, - chainId: token.chainId as Hex | CaipChainId, - decimals: token.decimals, - symbol: token.symbol, - name: token.name, - image: token.image, - securityData: adaptTokenSecurityData(token.securityData), - }; - - if (wantsToBuyToken) { - if (isNative) { - return { - sourceToken: getDefaultDestToken(bridgeToken.chainId), - destToken: bridgeToken, - }; - } - return { - sourceToken: getNativeSourceToken(bridgeToken.chainId), - destToken: bridgeToken, - }; - } - - return { - sourceToken: bridgeToken, - destToken: undefined, - }; -}; - -export interface UseTokenActionsResult { - onBuy: () => void; - onSend: () => Promise; - onReceive: () => void; - /** Sticky token-details Swap handler with balance-aware defaults */ - handleStickySwapPress: () => void; - /** Whether the user has any tokens with positive balance that can be used as a swap source */ - hasEligibleSwapTokens: boolean; - networkModal: React.ReactNode; -} - -export interface UseTokenActionsParams { - token: TokenI & { - transactionActiveAbTests?: TransactionActiveAbTestEntry[]; - }; - networkName?: string; - /** Optional up-to-date token balance from Token Details balance hook */ - currentTokenBalance?: string; - /** Page name sent with swap/bridge analytics. Defaults to `'MainView'`. */ - sourcePage?: string; -} + TokenActionInput, + useHandleOnBuy, + useHandleOnReceive, + useHandleOnSend, +} from './useTokenAtomicActions'; /** - * Hook that provides action handlers for token actions (buy, send, receive, swap). - * Extracts handler logic from AssetOverview.tsx. + * Composed hook for the Token Details Page Actions */ export const useTokenActions = ({ token, networkName, - currentTokenBalance, - sourcePage = 'MainView', -}: UseTokenActionsParams): UseTokenActionsResult => { - const navigation = useNavigation(); - - // Determine if token is EVM or non-EVM - const resultChainId = formatChainIdToCaip(token.chainId as Hex); - const isNonEvmToken = resultChainId === token.chainId; - - const chainId = token.chainId as Hex; - - // Selectors - const selectedInternalAccount = useSelector(selectSelectedInternalAccount); - const selectedAccountGroup = useSelector(selectSelectedAccountGroup); - const getAccountByScope = useSelector(selectSelectedInternalAccountByScope); - const selectedChainId = useSelector(selectEvmChainId); - const rampGeodetectedRegion = useSelector(getDetectedGeolocation); - const userAssetsMap = useSelector(selectAssetsBySelectedAccountGroup); - - // Metrics - const { trackEvent, createEventBuilder } = useAnalytics(); - - // Navigation hooks - const { navigateToSendPage } = useSendNavigation(); - const { goToBuy } = useRampNavigation(); - const rampsButtonClickData = useRampsButtonClickData(); - const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); - - // Swap/Bridge navigation - const { sourceToken, destToken } = getSwapTokens(token); - // When Token Details was opened from the bridge asset picker, skip updating - // the location on the bridge controller to preserve the original entry-point - // location from the session that opened the bridge (e.g. "Main View") - const isFromBridgeAssetPicker = - 'source' in token && token.source === TokenDetailsSource.Swap; - const { goToSwaps, networkModal } = useSwapBridgeNavigation({ - location: SwapBridgeNavigationLocation.TokenView, - sourcePage, - sourceToken, - destToken, - abTestContext: {}, - transactionActiveAbTests: token.transactionActiveAbTests, - skipLocationUpdate: isFromBridgeAssetPicker, - }); - - // Non-EVM send hook - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - const { sendNonEvmAsset } = useSendNonEvmAsset({ asset: token }); - ///: END:ONLY_INCLUDE_IF - - const onReceive = useCallback(() => { - trackActionButtonClick(trackEvent, createEventBuilder, { - action_name: ActionButtonType.RECEIVE, - action_position: ActionPosition.FOURTH_POSITION, - button_label: strings('asset_overview.receive_button'), - location: ActionLocation.ASSET_DETAILS, - }); - - const accountForChain = token.chainId - ? (getAccountByScope( - formatChainIdToCaip(token.chainId as Hex) as CaipChainId, - ) ?? selectedInternalAccount) - : selectedInternalAccount; - - const addressForChain = accountForChain?.address; - - if (addressForChain && selectedAccountGroup && chainId) { - navigation.navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { - screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.SHARE_ADDRESS_QR, - params: { - address: addressForChain, - networkName: networkName || 'Unknown Network', - chainId, - groupId: selectedAccountGroup.id, - }, - }); - } else { - Logger.error( - new Error( - 'useTokenActions::onReceive - Missing required data for navigation', - ), - { - hasAddress: !!addressForChain, - hasAccountGroup: !!selectedAccountGroup, - hasChainId: !!chainId, - isNonEvmAsset: isNonEvmToken, - assetChainId: token.chainId, - }, - ); - } - }, [ - trackEvent, - createEventBuilder, - isNonEvmToken, - token.chainId, - getAccountByScope, - selectedInternalAccount, - selectedAccountGroup, - chainId, - navigation, - networkName, - ]); - - const onSend = useCallback(async () => { - const sendEventProps = { - action_name: ActionButtonType.SEND, - action_position: ActionPosition.THIRD_POSITION, - button_label: strings('asset_overview.send_button'), - location: ActionLocation.ASSET_DETAILS, - }; - trackEvent( - createEventBuilder(MetaMetricsEvents.ACTION_BUTTON_CLICKED) - .addProperties(sendEventProps) - .build(), - ); - - const wasHandledAsNonEvm = await sendNonEvmAsset( - InitSendLocation.AssetOverview, - ); - if (wasHandledAsNonEvm) { - return; - } - - navigation.navigate(Routes.WALLET.HOME, { - screen: Routes.WALLET.TAB_STACK_FLOW, - params: { - screen: Routes.WALLET_VIEW, - }, - }); - - if (token.chainId !== selectedChainId) { - const { NetworkController, MultichainNetworkController } = Engine.context; - const networkConfiguration = - NetworkController.getNetworkConfigurationByChainId( - token.chainId as Hex, - ); - - const networkClientId = - networkConfiguration?.rpcEndpoints?.[ - networkConfiguration.defaultRpcEndpointIndex - ]?.networkClientId; - - await MultichainNetworkController.setActiveNetwork( - networkClientId as string, - ); - } - - navigateToSendPage({ - location: InitSendLocation.AssetOverview, - asset: token, - }); - }, [ - trackEvent, - createEventBuilder, - sendNonEvmAsset, - navigation, - token, - selectedChainId, - navigateToSendPage, - ]); - - const onBuy = useCallback(() => { - let assetId: string | undefined; - - trackActionButtonClick(trackEvent, createEventBuilder, { - action_name: ActionButtonType.BUY, - action_position: ActionPosition.FIRST_POSITION, - button_label: strings('asset_overview.buy_button'), - location: ActionLocation.ASSET_DETAILS, - }); - - try { - if (isCaipAssetType(token.address)) { - assetId = token.address; - } else { - assetId = parseRampIntent({ - chainId: getDecimalChainId(chainId), - address: token.address, - })?.assetId; - } - } catch { - assetId = undefined; - } - - trackEvent( - createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) - .addProperties({ - button_text: 'Buy', - location: 'TokenDetails', - chain_id_destination: getDecimalChainId(chainId), - ramp_type: rampUnifiedV1Enabled ? 'UNIFIED_BUY' : 'BUY', - region: rampGeodetectedRegion, - ramp_routing: rampsButtonClickData.ramp_routing, - is_authenticated: rampsButtonClickData.is_authenticated, - preferred_provider: rampsButtonClickData.preferred_provider, - order_count: rampsButtonClickData.order_count, - asset_symbol: token.symbol, - }) - .build(), - ); - - goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); - }, [ - trackEvent, - createEventBuilder, - token.address, - chainId, - rampUnifiedV1Enabled, - rampGeodetectedRegion, - rampsButtonClickData, - goToBuy, - token.symbol, - ]); - - // Convert current token to BridgeToken format (used as dest for Buy, source for Sell) - const currentTokenAsBridgeToken = useMemo( - () => ({ - address: token.address, - chainId: token.chainId as Hex | CaipChainId, - decimals: token.decimals, - symbol: token.symbol, - name: token.name, - image: token.image, - securityData: adaptTokenSecurityData(token.securityData), - rwaData: token.rwaData, - }), - [ - token.address, - token.chainId, - token.decimals, - token.symbol, - token.name, - token.image, - token.securityData, - token.rwaData, - ], - ); - - // Pre-compute source token for Buy (smart selection based on user assets) - // Returns null if no eligible tokens found (triggers on-ramp flow) - const buySourceToken = useMemo(() => { - // Flatten the assets map into an array - const userAssets = Object.values(userAssetsMap || {}).flat(); - - // Check if asset has positive fiat balance - const hasPositiveBalance = (a: { fiat?: { balance?: number } }) => - (a.fiat?.balance ?? 0) > 0; - - // Priority 1: Find highest USD value token on same chain (with positive balance) - // Note: assetId contains the token address for EVM assets - const sameChainAssets = userAssets - .filter( - (a) => - a.chainId === token.chainId && - !areAddressesEqual(a.assetId, token.address) && - hasPositiveBalance(a), - ) - .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); - - if (sameChainAssets.length > 0) { - const asset = sameChainAssets[0]; - return { - address: asset.assetId, - chainId: asset.chainId as Hex | CaipChainId, - decimals: asset.decimals, - symbol: asset.symbol, - name: asset.name, - image: asset.image, - }; - } - - // Eligible cross-chain assets: exclude exact same token (address + chain match) - // This allows cross-chain bridging of native tokens that share the zero address - const crossChainAssets = userAssets - .filter( - (a) => - !( - areAddressesEqual(a.assetId, token.address) && - a.chainId === token.chainId - ) && hasPositiveBalance(a), - ) - .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); - - // Priority 2: Prefer native tokens (ETH, POL, etc.) with highest fiat balance - const nativeAsset = crossChainAssets.find((a) => a.isNative); - if (nativeAsset) { - return { - address: nativeAsset.assetId, - chainId: nativeAsset.chainId as Hex | CaipChainId, - decimals: nativeAsset.decimals, - symbol: nativeAsset.symbol, - name: nativeAsset.name, - image: nativeAsset.image, - }; - } - - // Priority 3 – Last swapped token (needs selector/data source) - // Priority 4 – Most used token (needs selector/data source) - - // Fallback: highest USD value token on any chain - if (crossChainAssets.length > 0) { - const asset = crossChainAssets[0]; - return { - address: asset.assetId, - chainId: asset.chainId as Hex | CaipChainId, - decimals: asset.decimals, - symbol: asset.symbol, - name: asset.name, - image: asset.image, - }; - } - // No eligible tokens found - return null to trigger on-ramp flow - return null; - }, [userAssetsMap, token.chainId, token.address]); - - const currentTokenHasBalance = useMemo(() => { - const balanceToCheck = currentTokenBalance ?? token.balance; - - if (typeof balanceToCheck === 'number') { - return balanceToCheck > 0; - } - - if (typeof balanceToCheck === 'string') { - const parsedBalance = Number(balanceToCheck.replace(/,/gu, '').trim()); - return Number.isFinite(parsedBalance) && parsedBalance > 0; - } - - return false; - }, [currentTokenBalance, token.balance]); - - // Sticky Token Details swap button only: - // - If current token has balance, keep current token as source - // - If current token has no balance, prefill source with best available token and current as destination - const handleStickySwapPress = useCallback(() => { - if (!goToSwaps) return; - - if (currentTokenHasBalance) { - goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); - return; - } - - if (buySourceToken) { - goToSwaps(buySourceToken, currentTokenAsBridgeToken, undefined, true); - return; - } - - goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); - }, [ - goToSwaps, - currentTokenHasBalance, - currentTokenAsBridgeToken, - buySourceToken, - ]); +}: { + token: TokenActionInput; + networkName?: string; +}) => { + const onBuy = useHandleOnBuy({ token }); + const onSend = useHandleOnSend({ token }); + const onReceive = useHandleOnReceive({ token, networkName }); - return { - onBuy, - onSend, - onReceive, - handleStickySwapPress, - hasEligibleSwapTokens: buySourceToken !== null, - networkModal, - }; + return { onBuy, onSend, onReceive }; }; export default useTokenActions; diff --git a/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.test.ts new file mode 100644 index 00000000000..8bdc9d1e249 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.test.ts @@ -0,0 +1,1010 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { CaipChainId, Hex, isCaipAssetType } from '@metamask/utils'; +import { + AccountGroupAssets, + Asset, + TokenSecurityData, +} from '@metamask/assets-controllers'; +import { + computeBuySourceToken, + getSwapTokens, + useHandleOnBuy, + useHandleOnReceive, + useHandleOnSend, + useHandleOnSwap, +} from './useTokenAtomicActions'; +import { TokenI } from '../../Tokens/types'; +import { SecurityDataType } from '../../Bridge/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + ActionButtonType, + ActionPosition, + ActionLocation, +} from '../../../../util/analytics/actionButtonTracking'; +import Routes from '../../../../constants/navigation/Routes'; +import Logger from '../../../../util/Logger'; +import { selectEvmChainId } from '../../../../selectors/networkController'; +import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; +import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { selectAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; +import { + getDetectedGeolocation, + getOrders, + getRampRoutingDecision, +} from '../../../../reducers/fiatOrders'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; +import { getProviderToken } from '../../Ramp/Deposit/utils/ProviderTokenVault'; +import { TokenDetailsSource } from '../constants/constants'; +import { + createMockInternalAccount, + createMockAccountGroup, +} from '../../../../component-library/components-temp/MultichainAccounts/test-utils'; + +// Test Util - for mocking during edge case tests +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MockTestType = any; + +const mockStoreState = { mock: 'state' }; +const mockGetState = jest.fn(() => mockStoreState); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useStore: () => ({ getState: mockGetState }), +})); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})); + +jest.mock('../../../../selectors/networkController', () => ({ + ...jest.requireActual< + typeof import('../../../../selectors/networkController') + >('../../../../selectors/networkController'), + selectEvmChainId: jest.fn(), +})); + +jest.mock('../../../../selectors/accountsController', () => ({ + ...jest.requireActual< + typeof import('../../../../selectors/accountsController') + >('../../../../selectors/accountsController'), + selectSelectedInternalAccount: jest.fn(), +})); + +jest.mock( + '../../../../selectors/multichainAccounts/accountTreeController', + () => ({ + selectSelectedAccountGroup: jest.fn(), + }), +); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +jest.mock('../../../../selectors/assets/assets-list', () => ({ + selectAssetsBySelectedAccountGroup: jest.fn(), +})); + +jest.mock('../../../../reducers/fiatOrders', () => ({ + getDetectedGeolocation: jest.fn(), + getOrders: jest.fn(), + getRampRoutingDecision: jest.fn(), +})); + +jest.mock('../../../../selectors/rampsController', () => ({ + selectRampsOrdersForSelectedAccountGroup: jest.fn(), +})); + +jest.mock('../../Ramp/Deposit/utils/ProviderTokenVault', () => ({ + getProviderToken: jest.fn(), +})); + +jest.mock('../../Ramp/utils/determinePreferredProvider', () => ({ + completedOrdersFromFiatOrders: jest.fn(() => []), + completedOrdersFromRampsOrders: jest.fn(() => []), +})); + +const mockTrackEvent = jest.fn(); +const mockAddProperties = jest.fn().mockReturnThis(); +const mockBuild = jest.fn().mockReturnValue({}); +const createMockEventBuilder = () => ({ + addProperties: mockAddProperties, + build: mockBuild, +}); +const mockCreateEventBuilder = jest.fn(() => createMockEventBuilder()); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +/** + * Asserts `createEventBuilder` was invoked with `name` and `addProperties` was + * called with a payload that includes the given `properties` (subset match). + */ +const assertAnalyticsEvent = (name: unknown, properties?: unknown) => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith(name); + if (properties !== undefined) { + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining(properties as object), + ); + } +}; + +const mockNavigateToSendPage = jest.fn(); +jest.mock('../../../Views/confirmations/hooks/useSendNavigation', () => ({ + useSendNavigation: () => ({ + navigateToSendPage: mockNavigateToSendPage, + }), +})); + +const mockGoToBuy = jest.fn(); +jest.mock('../../Ramp/hooks/useRampNavigation', () => ({ + useRampNavigation: () => ({ + goToBuy: mockGoToBuy, + }), +})); + +const mockRampsUnifiedV1Enabled = jest.fn(() => false); +jest.mock('../../Ramp/hooks/useRampsUnifiedV1Enabled', () => ({ + __esModule: true, + default: () => mockRampsUnifiedV1Enabled(), +})); + +const mockSendNonEvmAsset = jest.fn().mockResolvedValue(false); +jest.mock('../../../hooks/useSendNonEvmAsset', () => ({ + useSendNonEvmAsset: () => ({ + sendNonEvmAsset: mockSendNonEvmAsset, + }), +})); + +const mockGoToSwaps = jest.fn(); +const mockUseSwapBridgeNavigation = jest.fn(() => ({ + goToSwaps: mockGoToSwaps, + networkModal: null, +})); +jest.mock('../../Bridge/hooks/useSwapBridgeNavigation', () => ({ + useSwapBridgeNavigation: (...args: unknown[]) => + mockUseSwapBridgeNavigation( + ...(args as Parameters), + ), + SwapBridgeNavigationLocation: { + MainView: 'MainView', + TokenView: 'TokenView', + TrendingExplore: 'TrendingExplore', + }, + isAssetFromTrending: jest.fn(() => false), +})); + +jest.mock('../../Bridge/utils/tokenUtils', () => ({ + getDefaultDestToken: jest.fn(), + getNativeSourceToken: jest.fn(), +})); + +jest.mock('@metamask/utils', () => ({ + ...jest.requireActual('@metamask/utils'), + isCaipAssetType: jest.fn(), +})); + +jest.mock('../../../../util/Logger'); + +jest.mock('../../../../core/Engine', () => ({ + context: { + NetworkController: { + getNetworkConfigurationByChainId: jest.fn(() => ({ + rpcEndpoints: [{ networkClientId: 'mainnet' }], + defaultRpcEndpointIndex: 0, + })), + }, + MultichainNetworkController: { + setActiveNetwork: jest.fn(), + }, + }, +})); + +const mockIsCaipAssetType = jest.mocked(isCaipAssetType); +const mockSelectEvmChainId = jest.mocked(selectEvmChainId); +const mockSelectSelectedInternalAccount = jest.mocked( + selectSelectedInternalAccount, +); +const mockSelectSelectedAccountGroup = jest.mocked(selectSelectedAccountGroup); +const mockSelectSelectedInternalAccountByScope = jest.mocked( + selectSelectedInternalAccountByScope, +); +const mockSelectAssetsBySelectedAccountGroup = jest.mocked( + selectAssetsBySelectedAccountGroup, +); +const mockGetDetectedGeolocation = jest.mocked(getDetectedGeolocation); +const mockGetOrders = jest.mocked(getOrders); +const mockGetRampRoutingDecision = jest.mocked(getRampRoutingDecision); +const mockSelectRampsOrdersForSelectedAccountGroup = jest.mocked( + selectRampsOrdersForSelectedAccountGroup, +); +const mockGetProviderToken = jest.mocked(getProviderToken); +const mockLoggerError = jest.mocked(Logger.error); + +const mockAccountAddress = '0x1234567890abcdef1234567890abcdef12345678'; + +const mockAccount = createMockInternalAccount( + 'account-1', + mockAccountAddress, + 'Account 1', +); + +const mockAccountGroup = createMockAccountGroup('group-1', 'Test Group', [ + mockAccountAddress, +]); + +const defaultToken: TokenI = { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: '0x1', + symbol: 'DAI', + decimals: 18, + name: 'Dai Stablecoin', + image: 'https://example.com/dai.png', + isETH: false, + isNative: false, +} as TokenI; + +const setupSelectorDefaults = () => { + mockSelectEvmChainId.mockReturnValue('0x1'); + mockSelectSelectedInternalAccount.mockReturnValue(mockAccount); + mockSelectSelectedAccountGroup.mockReturnValue(mockAccountGroup); + mockSelectSelectedInternalAccountByScope.mockReturnValue(() => mockAccount); + mockSelectAssetsBySelectedAccountGroup.mockReturnValue({}); + mockGetDetectedGeolocation.mockReturnValue('US'); + mockGetOrders.mockReturnValue([]); + mockGetRampRoutingDecision.mockReturnValue(null); + mockSelectRampsOrdersForSelectedAccountGroup.mockReturnValue([]); + mockGetProviderToken.mockResolvedValue({ + success: true, + token: { accessToken: 'access' }, + } as Awaited>); +}; + +/** + * Flushes the one-shot `getProviderToken` effect used by `useIsRampAuthenticated` + * so dependent assertions run after `is_authenticated` resolves. + */ +const flushAuthEffect = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +beforeEach(() => { + jest.clearAllMocks(); + setupSelectorDefaults(); + mockUseSwapBridgeNavigation.mockReturnValue({ + goToSwaps: mockGoToSwaps, + networkModal: null, + }); + mockRampsUnifiedV1Enabled.mockReturnValue(false); +}); + +describe('useTokenAtomicActions - getSwapTokens', () => { + it('returns sourceToken as the token and destToken as undefined for regular tokens', () => { + const result = getSwapTokens(defaultToken); + + expect(result.sourceToken).toMatchObject({ + address: defaultToken.address, + chainId: defaultToken.chainId, + symbol: defaultToken.symbol, + }); + expect(result.destToken).toBeUndefined(); + }); +}); + +/** + * `computeBuySourceToken` is the pure ranking helper used by `useHandleOnSwap` + * to pick a source token when the current token has no balance. + * + * Priority order tested: + * 1. Same-chain token with highest fiat (excluding current token) + * 2. Native token cross-chain with highest fiat + * 3. Fallback: any cross-chain token with highest fiat + * 4. Returns `null` when nothing eligible exists + */ +describe('useTokenAtomicActions - computeBuySourceToken', () => { + const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const POLYGON_USDC_ADDRESS = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'; + const POL_NATIVE_ADDRESS = '0x0000000000000000000000000000000000001010'; + const ETH_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; + + const userAsset = (params: { + assetId: string; + chainId?: string; + symbol?: string; + decimals?: number; + fiatBalance?: number; + isNative?: boolean; + }) => ({ + assetId: params.assetId, + chainId: params.chainId ?? '0x1', + decimals: params.decimals ?? 18, + symbol: params.symbol ?? 'SYM', + name: params.symbol ?? 'SYM', + image: '', + isNative: params.isNative ?? false, + ...(params.fiatBalance !== undefined + ? { fiat: { balance: params.fiatBalance } } + : {}), + }); + + it('Priority 1: picks the highest-fiat same-chain token excluding the current token', () => { + const result = computeBuySourceToken( + { + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 1000, + }), + userAsset({ + assetId: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + fiatBalance: 5000, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result?.address).toBe(USDC_ADDRESS); + }); + + it('Priority 1: excludes the current asset on the same chain', () => { + const result = computeBuySourceToken( + { + '0x1': [ + userAsset({ + assetId: defaultToken.address, + symbol: defaultToken.symbol, + fiatBalance: 9999, + }), + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 100, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result?.address).toBe(WETH_ADDRESS); + }); + + it('Priority 2: prefers the native cross-chain token over a higher-fiat non-native', () => { + const result = computeBuySourceToken( + { + '0x89': [ + userAsset({ + assetId: POLYGON_USDC_ADDRESS, + chainId: '0x89', + symbol: 'USDC', + decimals: 6, + fiatBalance: 5000, + }), + userAsset({ + assetId: POL_NATIVE_ADDRESS, + chainId: '0x89', + symbol: 'POL', + fiatBalance: 200, + isNative: true, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result?.address).toBe(POL_NATIVE_ADDRESS); + }); + + it('Priority 2: picks the native token with the highest fiat across chains', () => { + const result = computeBuySourceToken( + { + '0x89': [ + userAsset({ + assetId: POL_NATIVE_ADDRESS, + chainId: '0x89', + symbol: 'POL', + fiatBalance: 200, + isNative: true, + }), + ], + '0xa': [ + userAsset({ + assetId: ETH_NATIVE_ADDRESS, + chainId: '0xa', + symbol: 'ETH', + fiatBalance: 3000, + isNative: true, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result?.address).toBe(ETH_NATIVE_ADDRESS); + }); + + it('falls back to the highest-fiat non-native cross-chain token when no natives are eligible', () => { + const result = computeBuySourceToken( + { + '0x89': [ + userAsset({ + assetId: POLYGON_USDC_ADDRESS, + chainId: '0x89', + symbol: 'USDC', + decimals: 6, + fiatBalance: 800, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result?.address).toBe(POLYGON_USDC_ADDRESS); + }); + + it('returns null when only the current token has a positive fiat balance', () => { + const result = computeBuySourceToken( + { + '0x1': [ + userAsset({ + assetId: defaultToken.address, + symbol: defaultToken.symbol, + fiatBalance: 100, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result).toBeNull(); + }); + + it('returns null when no asset has a positive fiat balance', () => { + const result = computeBuySourceToken( + { + '0x1': [ + userAsset({ assetId: WETH_ADDRESS, symbol: 'WETH' }), + userAsset({ + assetId: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + fiatBalance: 0, + }), + ], + }, + defaultToken.chainId, + defaultToken.address, + ); + + expect(result).toBeNull(); + }); + + it('returns null for an undefined assets map', () => { + expect( + computeBuySourceToken( + undefined, + defaultToken.chainId, + defaultToken.address, + ), + ).toBeNull(); + }); +}); + +describe('useTokenAtomicActions - useHandleOnBuy', () => { + beforeEach(() => { + mockIsCaipAssetType.mockReturnValue(false); + }); + + /** + * Renders the hook and flushes the one-shot `getProviderToken` effect so + * that `is_authenticated` is resolved before assertions run. + */ + const renderOnBuy = async ( + params: Parameters[0] = { token: defaultToken }, + ) => { + const result = renderHook(() => useHandleOnBuy(params)); + await flushAuthEffect(); + return result; + }; + + it('calls goToBuy with parsed assetId and tracks ACTION_BUTTON_CLICKED', async () => { + const { result } = await renderOnBuy(); + + result.current(); + + expect(mockGoToBuy).toHaveBeenCalledTimes(1); + expect(mockGoToBuy).toHaveBeenCalledWith( + { + assetId: 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F', + }, + { buyFlowOrigin: 'tokenInfo' }, + ); + + assertAnalyticsEvent(MetaMetricsEvents.ACTION_BUTTON_CLICKED, { + action_name: ActionButtonType.BUY, + action_position: ActionPosition.FIRST_POSITION, + location: ActionLocation.ASSET_DETAILS, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + }); + + it('uses the token address directly as the assetId when it is a CAIP asset type', async () => { + const caipAddress = 'eip155:1/erc20:0xabc'; + mockIsCaipAssetType.mockReturnValue(true); + const caipToken = { ...defaultToken, address: caipAddress } as TokenI; + + const { result } = await renderOnBuy({ token: caipToken }); + + result.current(); + + expect(mockGoToBuy).toHaveBeenCalledWith( + { assetId: caipAddress }, + { buyFlowOrigin: 'tokenInfo' }, + ); + }); + + it('includes asset_symbol and ramp analytics in RAMPS_BUTTON_CLICKED event', async () => { + const { result } = await renderOnBuy(); + + result.current(); + + assertAnalyticsEvent(MetaMetricsEvents.RAMPS_BUTTON_CLICKED, { + location: 'TokenDetails', + asset_symbol: 'DAI', + is_authenticated: true, + region: 'US', + order_count: 0, + ramp_type: 'BUY', + }); + }); + + it('switches ramp_type to UNIFIED_BUY when the unified-v1 flag is enabled', async () => { + mockRampsUnifiedV1Enabled.mockReturnValue(true); + + const { result } = await renderOnBuy(); + result.current(); + + assertAnalyticsEvent(MetaMetricsEvents.RAMPS_BUTTON_CLICKED, { + ramp_type: 'UNIFIED_BUY', + }); + }); + + it('falls back to is_authenticated=false when ProviderTokenVault rejects', async () => { + mockGetProviderToken.mockRejectedValueOnce(new Error('no token')); + + const { result } = await renderOnBuy(); + + await waitFor(() => { + result.current(); + assertAnalyticsEvent(MetaMetricsEvents.RAMPS_BUTTON_CLICKED, { + is_authenticated: false, + }); + }); + }); +}); + +describe('useTokenAtomicActions - useHandleOnSend', () => { + beforeEach(() => { + mockSendNonEvmAsset.mockResolvedValue(false); + }); + + it('navigates to the send page and tracks analytics', async () => { + const { result } = renderHook(() => + useHandleOnSend({ token: defaultToken }), + ); + + await result.current(); + + assertAnalyticsEvent(MetaMetricsEvents.ACTION_BUTTON_CLICKED, { + action_name: ActionButtonType.SEND, + action_position: ActionPosition.THIRD_POSITION, + location: ActionLocation.ASSET_DETAILS, + }); + + expect(mockNavigateToSendPage).toHaveBeenCalledWith({ + location: 'asset_overview', + asset: defaultToken, + }); + }); + + it('skips the network switch when the token chain matches the selected evm chain', async () => { + mockSelectEvmChainId.mockReturnValue(defaultToken.chainId as `0x${string}`); + + const { result } = renderHook(() => + useHandleOnSend({ token: defaultToken }), + ); + + await result.current(); + + expect(mockNavigateToSendPage).toHaveBeenCalled(); + }); + + it('returns early when the token is handled by the non-EVM send flow', async () => { + mockSendNonEvmAsset.mockResolvedValueOnce(true); + + const { result } = renderHook(() => + useHandleOnSend({ token: defaultToken }), + ); + + await result.current(); + + expect(mockNavigateToSendPage).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +describe('useTokenAtomicActions - useHandleOnReceive', () => { + it('navigates to the share-address QR sheet and tracks analytics', () => { + const { result } = renderHook(() => + useHandleOnReceive({ + token: defaultToken, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current(); + + assertAnalyticsEvent(MetaMetricsEvents.ACTION_BUTTON_CLICKED, { + action_name: ActionButtonType.RECEIVE, + action_position: ActionPosition.FOURTH_POSITION, + location: ActionLocation.ASSET_DETAILS, + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, + { + screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.SHARE_ADDRESS_QR, + params: { + address: mockAccountAddress, + networkName: 'Ethereum Mainnet', + chainId: '0x1', + groupId: 'group-1', + }, + }, + ); + }); + + it('falls back to "Unknown Network" when networkName is not supplied', () => { + const { result } = renderHook(() => + useHandleOnReceive({ token: defaultToken }), + ); + + result.current(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, + expect.objectContaining({ + params: expect.objectContaining({ networkName: 'Unknown Network' }), + }), + ); + }); + + it('logs an error and does not navigate when the address cannot be resolved', () => { + mockSelectSelectedInternalAccount.mockReturnValue(null as MockTestType); + mockSelectSelectedInternalAccountByScope.mockReturnValue(() => undefined); + + const { result } = renderHook(() => + useHandleOnReceive({ + token: defaultToken, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current(); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + hasAddress: false, + hasAccountGroup: true, + hasChainId: true, + }), + ); + }); +}); + +// useHandleOnSwap decides between three flows at click time: +// 1. Has balance = Swap out of current token: goToSwaps(currentToken, undefined) +// 2. No balance + eligible source = Swap into current token: swap INTO the current token -> goToSwaps(buySource, currentToken) +// 3. No balance + no eligible source = Fall back to option 1 (swap out of current token): goToSwaps(currentToken, undefined) +// Source priority is documented under `computeBuySourceToken`. +describe('useTokenAtomicActions - useHandleOnSwap', () => { + const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + + const arrangeToken = (balance: string): TokenI => + ({ ...defaultToken, balance }) as TokenI; + + const userAsset = (params: { + assetId: Hex; + chainId?: Hex; + symbol: string; + decimals?: number; + fiatBalance?: number; + isNative?: boolean; + }): Asset => ({ + accountType: 'eip155:eoa', + assetId: params.assetId, + chainId: params.chainId ?? '0x1', + accountId: 'account-1', + address: params.assetId, + balance: `${params.fiatBalance ?? '0'}`, + rawBalance: '0x0', + fiat: { + balance: params.fiatBalance ?? 0, + currency: 'usd', + conversionRate: 1, + }, + decimals: params.decimals ?? 18, + symbol: params.symbol, + name: params.symbol, + image: '', + isNative: params.isNative ?? false, + }); + + it('returns early when goToSwaps is not provided by the navigation hook', () => { + mockUseSwapBridgeNavigation.mockReturnValueOnce({ + goToSwaps: undefined as MockTestType, + networkModal: null, + }); + + const { result } = renderHook(() => + useHandleOnSwap({ token: arrangeToken('1') }), + ); + + expect(() => result.current()).not.toThrow(); + expect(mockGoToSwaps).not.toHaveBeenCalled(); + }); + + // Cases where the current token has balance, so is indicated as a source token (swap out of the current token) + const swapOutOfCurrentTokenRoutingCases = [ + { + description: + 'swaps from the current token when token.balance is positive', + assetsByGroup: {}, + getHookParams: () => ({ token: arrangeToken('1') }), + assertSwapCall: (sourceToken: unknown, destToken: unknown) => { + expect(sourceToken).toStrictEqual( + expect.objectContaining({ address: defaultToken.address }), + ); + expect(destToken).toBeUndefined(); + }, + }, + { + description: + 'treats comma-formatted token.balance as positive when routing swap', + assetsByGroup: {}, + getHookParams: () => ({ token: arrangeToken('1,000.50') }), + assertSwapCall: (sourceToken: unknown, destToken: unknown) => { + expect(sourceToken).toStrictEqual( + expect.objectContaining({ address: defaultToken.address }), + ); + expect(destToken).toBeUndefined(); + }, + }, + { + description: + 'prefers currentTokenBalance over token.balance when checking positivity', + assetsByGroup: { + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 9000, + }), + ], + }, + getHookParams: () => ({ + token: arrangeToken('0'), + currentTokenBalance: '0.5', + }), + assertSwapCall: (sourceToken: unknown, destToken: unknown) => { + expect(sourceToken).toStrictEqual( + expect.objectContaining({ address: defaultToken.address }), + ); + expect(destToken).toBeUndefined(); + }, + }, + ]; + + // Cases where the current token has no balance, so is indicated as a destination token (swap into the current token) + const swapIntoCurrentTokenRoutingCases = [ + { + description: + 'swaps into the current token when balance is zero and an eligible buy source exists', + assetsByGroup: { + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 1000, + }), + ], + }, + getHookParams: () => ({ token: arrangeToken('0') }), + assertSwapCall: (sourceToken: unknown, destToken: unknown) => { + expect(sourceToken).toStrictEqual( + expect.objectContaining({ address: WETH_ADDRESS }), + ); + expect(destToken).toStrictEqual( + expect.objectContaining({ address: defaultToken.address }), + ); + }, + }, + ]; + + // Cases where the logic is not able to determine source or destination tokens, so falls back to "swap out of the current token" + const swapFallbackRoutingCases = [ + { + description: + 'falls back to the current token as source when no eligible buy source exists', + assetsByGroup: {}, + getHookParams: () => ({ token: arrangeToken('0') }), + assertSwapCall: (sourceToken: unknown, destToken: unknown) => { + expect(sourceToken).toStrictEqual( + expect.objectContaining({ address: defaultToken.address }), + ); + expect(destToken).toBeUndefined(); + }, + }, + ]; + + it.each([ + ...swapOutOfCurrentTokenRoutingCases, + ...swapIntoCurrentTokenRoutingCases, + ...swapFallbackRoutingCases, + ])('$description', ({ assetsByGroup, getHookParams, assertSwapCall }) => { + mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + assetsByGroup as AccountGroupAssets, + ); + + const { result } = renderHook(() => useHandleOnSwap(getHookParams())); + + result.current(); + expect(mockGoToSwaps).toHaveBeenCalled(); + + const [sourceToken, destToken] = mockGoToSwaps.mock.lastCall ?? []; + assertSwapCall(sourceToken, destToken); + }); +}); + +describe('useTokenAtomicActions - useHandleOnSwap securityData adaptation', () => { + const buildTrendingSecurityData = ( + overrides: Partial = {}, + ): TokenSecurityData => + ({ + resultType: 'Warning', + maliciousScore: '50', + fees: { + transfer: 0, + transferFeeMaxAmount: null, + buy: 0, + sell: null, + }, + features: [ + { + featureId: 'HONEYPOT', + type: 'Warning', + description: 'Honeypot risk', + }, + ], + financialStats: { + supply: 0, + topHolders: [], + holdersCount: 0, + tradeVolume24h: null, + lockedLiquidityPct: null, + markets: [], + }, + metadata: { + externalLinks: { + homepage: null, + twitterPage: null, + telegramChannelId: null, + }, + }, + created: '2025-01-01T00:00:00Z', + ...overrides, + }) as TokenSecurityData; + + it("adapts trending-shape securityData to the bridge's legacy shape", () => { + const tokenWithSecurity = { + ...defaultToken, + balance: '1', + securityData: buildTrendingSecurityData(), + } as TokenI; + + const { result } = renderHook(() => + useHandleOnSwap({ token: tokenWithSecurity }), + ); + + result.current(); + + expect(mockGoToSwaps).toHaveBeenCalledWith( + expect.objectContaining({ + address: defaultToken.address, + securityData: { + type: SecurityDataType.Warning, + metadata: { + features: [ + { + featureId: 'HONEYPOT', + type: SecurityDataType.Warning, + description: 'Honeypot risk', + }, + ], + }, + }, + }), + undefined, + undefined, + true, + ); + }); + + it('passes securityData as undefined when the token has no security data', () => { + const tokenWithBalance = { + ...defaultToken, + balance: '1', + } as TokenI; + + const { result } = renderHook(() => + useHandleOnSwap({ token: tokenWithBalance }), + ); + + result.current(); + + expect(mockGoToSwaps).toHaveBeenCalledWith( + expect.objectContaining({ + address: defaultToken.address, + securityData: undefined, + }), + undefined, + undefined, + true, + ); + }); + + it('forwards rwaData so selectIsRwaSwap can detect the convert flow', () => { + const rwaToken = { + ...defaultToken, + balance: '1', + rwaData: { instrumentType: 'stock' } as TokenI['rwaData'], + } as TokenI; + + const { result } = renderHook(() => useHandleOnSwap({ token: rwaToken })); + + result.current(); + + expect(mockGoToSwaps).toHaveBeenCalledWith( + expect.objectContaining({ + address: defaultToken.address, + rwaData: { instrumentType: 'stock' }, + }), + undefined, + undefined, + true, + ); + }); +}); diff --git a/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.ts b/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.ts new file mode 100644 index 00000000000..04fc0246a73 --- /dev/null +++ b/app/components/UI/TokenDetails/hooks/useTokenAtomicActions.ts @@ -0,0 +1,542 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; +import { useStore } from 'react-redux'; +import { Hex, CaipChainId, isCaipAssetType } from '@metamask/utils'; +import { strings } from '../../../../../locales/i18n'; +import Engine from '../../../../core/Engine'; +import { selectEvmChainId } from '../../../../selectors/networkController'; +import { selectSelectedInternalAccount } from '../../../../selectors/accountsController'; +import Logger from '../../../../util/Logger'; +import Routes from '../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { getDecimalChainId } from '../../../../util/networks'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { + trackActionButtonClick, + ActionButtonType, + ActionLocation, + ActionPosition, +} from '../../../../util/analytics/actionButtonTracking'; +import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { areAddressesEqual } from '../../../../util/address'; +import { useRampNavigation } from '../../Ramp/hooks/useRampNavigation'; +import { TokenI } from '../../Tokens/types'; +import { + isAssetFromTrending, + useSwapBridgeNavigation, + SwapBridgeNavigationLocation, +} from '../../Bridge/hooks/useSwapBridgeNavigation'; +import { NATIVE_SWAPS_TOKEN_ADDRESS } from '../../../../constants/bridge'; +import { + getNativeSourceToken, + getDefaultDestToken, +} from '../../Bridge/utils/tokenUtils'; +import { useSendNonEvmAsset } from '../../../hooks/useSendNonEvmAsset'; +import { + formatChainIdToCaip, + isNativeAddress, +} from '@metamask/bridge-controller'; +import { InitSendLocation } from '../../../Views/confirmations/constants/send'; +import { useSendNavigation } from '../../../Views/confirmations/hooks/useSendNavigation'; +import parseRampIntent from '../../Ramp/utils/parseRampIntent'; +import { + getDetectedGeolocation, + getOrders, + getRampRoutingDecision, +} from '../../../../reducers/fiatOrders'; +import { selectRampsOrdersForSelectedAccountGroup } from '../../../../selectors/rampsController'; +import { getProviderToken } from '../../Ramp/Deposit/utils/ProviderTokenVault'; +import { + completedOrdersFromFiatOrders, + completedOrdersFromRampsOrders, +} from '../../Ramp/utils/determinePreferredProvider'; +import useRampsUnifiedV1Enabled from '../../Ramp/hooks/useRampsUnifiedV1Enabled'; +import { BridgeToken } from '../../Bridge/types'; +import { adaptTokenSecurityData } from '../../Bridge/utils/tokenSecurityUtils'; +import { selectAssetsBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; +import { TokenDetailsSource } from '../constants/constants'; +import type { RootState } from '../../../../reducers'; +import type { TransactionActiveAbTestEntry } from '../../../../util/transactions/transaction-active-ab-test-attribution-registry'; + +export type TokenActionInput = TokenI & { + transactionActiveAbTests?: TransactionActiveAbTestEntry[]; + source?: TokenDetailsSource; +}; + +interface BuySourceAsset { + chainId: string; + assetId: string; + isNative?: boolean; + decimals: number; + symbol: string; + name: string; + image?: string; + fiat?: { balance?: number }; +} + +/** + * Smart picker for the swap "source" token when the current token has no + * balance: pick the user's best available asset (highest fiat) to spend. + * + * Pure function so it can be invoked lazily at click time (avoiding the + * full sort/rank pass on every redux update). Mirrors the priority order + * used by the legacy `buySourceToken` memo: + * 1. Highest USD-value token on the same chain (excluding current token) + * 2. Native token with highest USD value across other chains + * 3. Fallback: highest USD-value token across any chain + */ +export const computeBuySourceToken = ( + userAssetsMap: Record | undefined, + tokenChainId: string | undefined, + tokenAddress: string, +): BridgeToken | null => { + const userAssets = Object.values(userAssetsMap || {}).flat(); + + // Check if asset has positive fiat balance + const hasPositiveFiat = (a: { fiat?: { balance?: number } }) => + (a.fiat?.balance ?? 0) > 0; + + // Priority 1: Find highest USD value token on same chain (with positive balance) + // Note: assetId contains the token address for EVM assets + const sameChainAssets = userAssets + .filter( + (a) => + a.chainId === tokenChainId && + !areAddressesEqual(a.assetId, tokenAddress) && + hasPositiveFiat(a), + ) + .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); + + if (sameChainAssets.length > 0) { + const asset = sameChainAssets[0]; + return { + address: asset.assetId, + chainId: asset.chainId as Hex | CaipChainId, + decimals: asset.decimals, + symbol: asset.symbol, + name: asset.name, + image: asset.image, + }; + } + + // Eligible cross-chain assets: exclude exact same token (address + chain match) + // This allows cross-chain bridging of native tokens that share the zero address + const crossChainAssets = userAssets + .filter( + (a) => + !( + areAddressesEqual(a.assetId, tokenAddress) && + a.chainId === tokenChainId + ) && hasPositiveFiat(a), + ) + .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); + + // Priority 2: Prefer native tokens (ETH, POL, etc.) with highest fiat balance + const nativeAsset = crossChainAssets.find((a) => a.isNative); + if (nativeAsset) { + return { + address: nativeAsset.assetId, + chainId: nativeAsset.chainId as Hex | CaipChainId, + decimals: nativeAsset.decimals, + symbol: nativeAsset.symbol, + name: nativeAsset.name, + image: nativeAsset.image, + }; + } + + // Priority 3 – Last swapped token (needs selector/data source) + // Priority 4 – Most used token (needs selector/data source) + + // Fallback: highest USD value token on any chain + if (crossChainAssets.length > 0) { + const asset = crossChainAssets[0]; + return { + address: asset.assetId, + chainId: asset.chainId as Hex | CaipChainId, + decimals: asset.decimals, + symbol: asset.symbol, + name: asset.name, + image: asset.image, + }; + } + // No eligible tokens found - return null to trigger on-ramp flow + return null; +}; + +const toCurrentTokenAsBridgeToken = (token: TokenI): BridgeToken => ({ + ...token, + address: token.address, + chainId: token.chainId as Hex | CaipChainId, + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + image: token.image, + securityData: adaptTokenSecurityData(token.securityData), + rwaData: token.rwaData, +}); + +const hasPositiveBalance = (balance: string | number | undefined): boolean => { + if (typeof balance === 'number') { + return balance > 0; + } + + if (typeof balance === 'string') { + const parsed = Number(balance.replace(/,/gu, '').trim()); + return Number.isFinite(parsed) && parsed > 0; + } + + return false; +}; + +/** + * Determines the source and destination tokens for swap/bridge navigation. + */ +export const getSwapTokens = ( + token: TokenI, +): { + sourceToken: BridgeToken | undefined; + destToken: BridgeToken | undefined; +} => { + const wantsToBuyToken = isAssetFromTrending(token); + const isNative = isNativeAddress(token.address); + + const bridgeToken: BridgeToken = { + ...token, + address: token.address ?? NATIVE_SWAPS_TOKEN_ADDRESS, + chainId: token.chainId as Hex | CaipChainId, + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + image: token.image, + securityData: adaptTokenSecurityData(token.securityData), + }; + + if (wantsToBuyToken) { + if (isNative) { + return { + sourceToken: getDefaultDestToken(bridgeToken.chainId), + destToken: bridgeToken, + }; + } + return { + sourceToken: getNativeSourceToken(bridgeToken.chainId), + destToken: bridgeToken, + }; + } + + return { + sourceToken: bridgeToken, + destToken: undefined, + }; +}; + +/** + * Mounts a one-shot async lookup against the ramp provider vault so the + * `is_authenticated` analytics property stays accurate without subscribing + * to anything in the redux tree on every tick. + * + * Returns `false` until the lookup resolves, then flips to the resolved + * value. Used by `useHandleOnBuy` to enrich the `RAMPS_BUTTON_CLICKED` + * event payload. + */ +const useIsRampAuthenticated = (): boolean => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + useEffect(() => { + let mounted = true; + getProviderToken() + .then((tokenResponse) => { + if (!mounted) return; + setIsAuthenticated( + tokenResponse.success && Boolean(tokenResponse.token?.accessToken), + ); + }) + .catch(() => { + if (!mounted) return; + setIsAuthenticated(false); + }); + return () => { + mounted = false; + }; + }, []); + return isAuthenticated; +}; + +/** + * Atomic hook returning the click handler for the Buy CTA on Token Details + */ +export const useHandleOnBuy = ({ token }: { token: TokenActionInput }) => { + const store = useStore(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const { goToBuy } = useRampNavigation(); + const rampUnifiedV1Enabled = useRampsUnifiedV1Enabled(); + const isAuthenticated = useIsRampAuthenticated(); + + return useCallback(() => { + const tokenChainIdHex = token.chainId as Hex; + + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.BUY, + action_position: ActionPosition.FIRST_POSITION, + button_label: strings('asset_overview.buy_button'), + location: ActionLocation.ASSET_DETAILS, + }); + + let assetId: string | undefined; + try { + if (isCaipAssetType(token.address)) { + assetId = token.address; + } else { + assetId = parseRampIntent({ + chainId: getDecimalChainId(tokenChainIdHex), + address: token.address, + })?.assetId; + } + } catch { + assetId = undefined; + } + + const state = store.getState(); + const rampGeodetectedRegion = getDetectedGeolocation(state); + const orders = getOrders(state); + const controllerOrders = selectRampsOrdersForSelectedAccountGroup(state); + const rampRoutingDecision = getRampRoutingDecision(state); + + const completedOrders = [ + ...completedOrdersFromFiatOrders(orders), + ...completedOrdersFromRampsOrders(controllerOrders), + ]; + let preferredProvider: string | undefined; + if (completedOrders.length > 0) { + const [mostRecent] = [...completedOrders].sort( + (a, b) => b.completedAt - a.completedAt, + ); + preferredProvider = mostRecent.providerId; + } + + trackEvent( + createEventBuilder(MetaMetricsEvents.RAMPS_BUTTON_CLICKED) + .addProperties({ + button_text: 'Buy', + location: 'TokenDetails', + chain_id_destination: getDecimalChainId(tokenChainIdHex), + ramp_type: rampUnifiedV1Enabled ? 'UNIFIED_BUY' : 'BUY', + region: rampGeodetectedRegion, + ramp_routing: rampRoutingDecision ?? undefined, + is_authenticated: isAuthenticated, + preferred_provider: preferredProvider, + order_count: orders.length + controllerOrders.length, + asset_symbol: token.symbol, + }) + .build(), + ); + + goToBuy({ assetId }, { buyFlowOrigin: 'tokenInfo' }); + }, [ + store, + token, + trackEvent, + createEventBuilder, + rampUnifiedV1Enabled, + isAuthenticated, + goToBuy, + ]); +}; + +/** + * Atomic hook returning the click handler for the Swap CTA on Token Details + */ +export const useHandleOnSwap = ({ + token, + currentTokenBalance, + sourcePage = 'MainView', +}: { + token: TokenActionInput; + /** Optional up-to-date token balance from Token Details balance hook */ + currentTokenBalance?: string; + /** Page name sent with swap/bridge analytics. Defaults to `'MainView'`. */ + sourcePage?: string; +}) => { + const store = useStore(); + + // When Token Details was opened from the bridge asset picker, skip updating + // the location on the bridge controller to preserve the original entry-point + // location from the session that opened the bridge (e.g. "Main View"). + const isFromBridgeAssetPicker = token.source === TokenDetailsSource.Swap; + + const { goToSwaps } = useSwapBridgeNavigation({ + location: SwapBridgeNavigationLocation.TokenView, + sourcePage, + transactionActiveAbTests: token.transactionActiveAbTests, + skipLocationUpdate: isFromBridgeAssetPicker, + }); + + return useCallback(() => { + if (!goToSwaps) return; + + const currentTokenAsBridgeToken = toCurrentTokenAsBridgeToken(token); + const balanceForCheck = currentTokenBalance ?? token.balance; + + if (hasPositiveBalance(balanceForCheck)) { + goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); + return; + } + + // Lazily compute the smart pick — only on press, only when needed. + const userAssetsMap = selectAssetsBySelectedAccountGroup(store.getState()); + const buySourceToken = computeBuySourceToken( + userAssetsMap, + token.chainId, + token.address, + ); + + if (buySourceToken) { + goToSwaps(buySourceToken, currentTokenAsBridgeToken, undefined, true); + return; + } + + goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); + }, [goToSwaps, store, token, currentTokenBalance]); +}; + +/** + * Atomic hook returning the click handler for the Send CTA on Token Details. + * + * Switches to the token's chain (when needed) and routes through the + * non-EVM send flow first; falls through to the EVM send page otherwise. + */ +export const useHandleOnSend = ({ token }: { token: TokenActionInput }) => { + const navigation = useNavigation(); + const store = useStore(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const { navigateToSendPage } = useSendNavigation(); + + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + const { sendNonEvmAsset } = useSendNonEvmAsset({ asset: token }); + ///: END:ONLY_INCLUDE_IF + + return useCallback(async () => { + trackEvent( + createEventBuilder(MetaMetricsEvents.ACTION_BUTTON_CLICKED) + .addProperties({ + action_name: ActionButtonType.SEND, + action_position: ActionPosition.THIRD_POSITION, + button_label: strings('asset_overview.send_button'), + location: ActionLocation.ASSET_DETAILS, + }) + .build(), + ); + + const wasHandledAsNonEvm = await sendNonEvmAsset( + InitSendLocation.AssetOverview, + ); + if (wasHandledAsNonEvm) { + return; + } + + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + + const selectedChainId = selectEvmChainId(store.getState()); + + if (token.chainId !== selectedChainId) { + const { NetworkController, MultichainNetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + token.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + await MultichainNetworkController.setActiveNetwork( + networkClientId as string, + ); + } + + navigateToSendPage({ + location: InitSendLocation.AssetOverview, + asset: token, + }); + }, [ + trackEvent, + createEventBuilder, + sendNonEvmAsset, + navigation, + store, + navigateToSendPage, + token, + ]); +}; + +/** + * Atomic hook returning the click handler for the Receive CTA on Token Details. + */ +export const useHandleOnReceive = ({ + token, + networkName, +}: { + token: TokenActionInput; + /** Optional network name displayed in the share-address QR sheet. */ + networkName?: string; +}) => { + const navigation = useNavigation(); + const store = useStore(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + return useCallback(() => { + const chainId = token.chainId as Hex; + + trackActionButtonClick(trackEvent, createEventBuilder, { + action_name: ActionButtonType.RECEIVE, + action_position: ActionPosition.FOURTH_POSITION, + button_label: strings('asset_overview.receive_button'), + location: ActionLocation.ASSET_DETAILS, + }); + + const state = store.getState(); + const selectedInternalAccount = selectSelectedInternalAccount(state); + const selectedAccountGroup = selectSelectedAccountGroup(state); + const getAccountByScope = selectSelectedInternalAccountByScope(state); + + const accountForChain = token.chainId + ? (getAccountByScope( + formatChainIdToCaip(token.chainId as Hex) as CaipChainId, + ) ?? selectedInternalAccount) + : selectedInternalAccount; + + const addressForChain = accountForChain?.address; + + if (addressForChain && selectedAccountGroup && chainId) { + navigation.navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { + screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.SHARE_ADDRESS_QR, + params: { + address: addressForChain, + networkName: networkName || 'Unknown Network', + chainId, + groupId: selectedAccountGroup.id, + }, + }); + } else { + const resultChainId = formatChainIdToCaip(token.chainId as Hex); + const isNonEvmToken = resultChainId === token.chainId; + + Logger.error( + new Error('useHandleOnReceive - Missing required data for navigation'), + { + hasAddress: !!addressForChain, + hasAccountGroup: !!selectedAccountGroup, + hasChainId: !!chainId, + isNonEvmAsset: isNonEvmToken, + assetChainId: token.chainId, + }, + ); + } + }, [trackEvent, createEventBuilder, store, navigation, token, networkName]); +}; diff --git a/app/components/hooks/useCurrentNetworkInfo.ts b/app/components/hooks/useCurrentNetworkInfo.ts index 4d9abfa4427..d7eb3d4f0c7 100644 --- a/app/components/hooks/useCurrentNetworkInfo.ts +++ b/app/components/hooks/useCurrentNetworkInfo.ts @@ -103,13 +103,24 @@ export const useCurrentNetworkInfo = (): CurrentNetworkInfo => { const hasEnabledNetworks = enabledNetworks.length > 0; - return { - enabledNetworks, - getNetworkInfo, - getNetworkInfoByChainId, - isDisabled, - hasEnabledNetworks, - isNetworkEnabledForDefi, - hasMultipleNamespacesEnabled, - }; + return useMemo( + () => ({ + enabledNetworks, + getNetworkInfo, + getNetworkInfoByChainId, + isDisabled, + hasEnabledNetworks, + isNetworkEnabledForDefi, + hasMultipleNamespacesEnabled, + }), + [ + enabledNetworks, + getNetworkInfo, + getNetworkInfoByChainId, + isDisabled, + hasEnabledNetworks, + isNetworkEnabledForDefi, + hasMultipleNamespacesEnabled, + ], + ); }; diff --git a/app/selectors/assets/assets-list.test.ts b/app/selectors/assets/assets-list.test.ts index 205dfff8b75..0ce6edad11f 100644 --- a/app/selectors/assets/assets-list.test.ts +++ b/app/selectors/assets/assets-list.test.ts @@ -17,6 +17,7 @@ import { selectAsset, selectAssetsByAccountGroupId, selectAssetsBySelectedAccountGroup, + selectHasEligibleSwapSource, selectSortedAssetsBySelectedAccountGroup, selectSortedAssetsBySelectedAccountGroupForChainIds, selectSortedAssetsBySelectedAccountGroupForChainIdsByBalance, @@ -2085,3 +2086,231 @@ describe('selectAssetsByAccountGroupId', () => { expect(result).toStrictEqual({}); }); }); + +describe('selectHasEligibleSwapSource', () => { + const ETH_MAINNET = '0x1'; + const OPTIMISM = '0xa'; + const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F'; + const USDC_ADDRESS = '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85'; + const STETH_ADDRESS = '0xae7ab96520de3a18e5e111b5eaab095312d7fe84'; + + interface AssetFixture { + assetId: string; + chainId: string; + fiat?: { balance: number }; + } + + // Arrange utilities ---------------------------------------------------- + + /** Build a single asset fixture with sensible defaults (positive fiat balance). */ + const buildAsset = (overrides: Partial = {}): AssetFixture => ({ + assetId: DAI_ADDRESS, + chainId: ETH_MAINNET, + fiat: { balance: 100 }, + ...overrides, + }); + + /** Group a flat list of assets by chainId into the shape produced by selectAssetsBySelectedAccountGroup. */ + const buildAssetsByChain = ( + assets: AssetFixture[], + ): Record => + assets.reduce>((acc, asset) => { + (acc[asset.chainId] ??= []).push(asset); + return acc; + }, {}); + + /** + * Invokes the selector's pure result function so each test can focus on + * behavior rather than rebuilding the full Redux state shape. + */ + const runSelector = ( + assets: AssetFixture[], + excludedChainId?: string, + excludedAddress?: string, + ): boolean => + selectHasEligibleSwapSource.resultFunc( + buildAssetsByChain(assets) as never, + excludedChainId, + excludedAddress, + ); + + describe('when no exclusion is provided', () => { + const noExclusionReturnsTrueCases = [ + { + description: 'returns true when any asset has positive fiat balance', + getAssets: () => [buildAsset({ fiat: { balance: 50 } })], + expected: true, + }, + { + description: + 'returns true when at least one chain holds a positive-fiat asset', + getAssets: () => [ + buildAsset({ chainId: ETH_MAINNET, fiat: { balance: 0 } }), + buildAsset({ chainId: OPTIMISM, fiat: { balance: 50 } }), + ], + expected: true, + }, + ]; + + const noExclusionReturnsFalseCases = [ + { + description: 'returns false when the asset map is empty', + getAssets: () => [] as AssetFixture[], + expected: false, + }, + { + description: 'returns false when every asset has a zero fiat balance', + getAssets: () => [ + buildAsset({ assetId: DAI_ADDRESS, fiat: { balance: 0 } }), + buildAsset({ assetId: USDC_ADDRESS, fiat: { balance: 0 } }), + ], + expected: false, + }, + { + description: + 'returns false when every asset has a negative fiat balance', + getAssets: () => [buildAsset({ fiat: { balance: -1 } })], + expected: false, + }, + { + description: 'returns false when no asset has a fiat property', + getAssets: () => [buildAsset({ fiat: undefined })], + expected: false, + }, + ]; + + it.each([...noExclusionReturnsTrueCases, ...noExclusionReturnsFalseCases])( + '$description', + ({ getAssets, expected }) => { + expect(runSelector(getAssets())).toBe(expected); + }, + ); + }); + + describe('when an excluded chainId and address are provided', () => { + type TestAsset = '0x1:DAI' | '0xa:DAI' | '0x1:USDC' | '0xa:USDC'; + const buildAssets = (testAsset: TestAsset[]) => + testAsset.map((asset) => { + const [chainId, testAssetName] = asset.split(':'); + + let assetId = DAI_ADDRESS; + if (testAssetName === 'USDC') { + assetId = USDC_ADDRESS; + } else if (testAssetName === 'DAI') { + assetId = DAI_ADDRESS; + } + + return buildAsset({ assetId, chainId }); + }); + + const buildOmitInput = (testAsset: TestAsset) => { + const [chainId, testAssetName] = testAsset.split(':'); + const excludedChainId = chainId; + let assetId = DAI_ADDRESS; + if (testAssetName === 'USDC') { + assetId = USDC_ADDRESS; + } else if (testAssetName === 'DAI') { + assetId = DAI_ADDRESS; + } + + return { + excludedChainId, + excludedAddress: assetId, + }; + }; + + const exclusionProvidedCases = [ + { + description: + 'returns false when the only positive-fiat asset matches the exclusion', + getInputs: () => ({ + assets: buildAssets(['0x1:DAI']), + ...buildOmitInput('0x1:DAI'), // 0x1:DAI is excluded + }), + assertResult: (result: boolean) => expect(result).toBe(false), + }, + { + description: + 'returns true when a non-excluded asset on another chain still qualifies', + getInputs: () => ({ + assets: buildAssets(['0x1:DAI', '0xa:USDC']), + ...buildOmitInput('0x1:DAI'), // 0xa:USDC is still valid + }), + assertResult: (result: boolean) => expect(result).toBe(true), + }, + { + description: + 'returns true when the excluded chainId matches but the address differs', + getInputs: () => ({ + assets: buildAssets(['0x1:DAI']), + ...buildOmitInput('0x1:USDC'), // 0x1:DAI is still valid + }), + assertResult: (result: boolean) => expect(result).toBe(true), + }, + { + description: + 'returns true when the excluded address matches but the chainId differs', + getInputs: () => ({ + assets: buildAssets(['0xa:DAI']), + ...buildOmitInput('0x1:DAI'), // 0x1:DAI is still valid + }), + assertResult: (result: boolean) => expect(result).toBe(true), + }, + { + description: + 'skips assets with non-positive fiat before checking exclusion', + getInputs: () => { + const assets = buildAssets(['0x1:USDC', '0x1:DAI']); + assets[0].fiat = { balance: 0 }; + return { + assets, + ...buildOmitInput('0x1:DAI'), // 0x1:USDC skipped, 0x1:DAI is excluded + }; + }, + assertResult: (result: boolean) => expect(result).toBe(false), + }, + ]; + + it.each(exclusionProvidedCases)( + '$description', + ({ getInputs, assertResult }) => { + const { assets, excludedChainId, excludedAddress } = getInputs(); + assertResult(runSelector(assets, excludedChainId, excludedAddress)); + }, + ); + }); + + describe('integrated with Redux state', () => { + const integratedReturnsTrueCases = [ + { + description: + 'returns true for the default mockState since multiple positive-fiat assets exist', + excludedChainId: undefined, + excludedAddress: undefined, + }, + { + description: + 'returns true when one EVM asset is excluded but other positive-fiat assets remain', + excludedChainId: ETH_MAINNET, + excludedAddress: STETH_ADDRESS, + }, + { + description: + 'returns true when a non-EVM asset is the exclusion target', + excludedChainId: SOLANA, + excludedAddress: `${SOLANA}/slip44:501`, + }, + ]; + + it.each(integratedReturnsTrueCases)( + '$description', + ({ excludedChainId, excludedAddress }) => { + const state = mockState(); + expect( + selectHasEligibleSwapSource(state, excludedChainId, excludedAddress), + ).toBe(true); + }, + ); + }); +}); diff --git a/app/selectors/assets/assets-list.ts b/app/selectors/assets/assets-list.ts index 89bfe67144e..ed66ab39189 100644 --- a/app/selectors/assets/assets-list.ts +++ b/app/selectors/assets/assets-list.ts @@ -3,7 +3,6 @@ import { selectAllAssets as _selectAllAssets, selectAssetsBySelectedAccountGroup as _selectAssetsBySelectedAccountGroup, getNativeTokenAddress, - TokenListState, AssetListState, AccountGroupAssets, } from '@metamask/assets-controllers'; @@ -160,6 +159,40 @@ export const selectAssetsBySelectedAccountGroup = createDeepEqualSelector( (assetsState) => callSelectAssetsBySelectedAccountGroup(assetsState), ); +/** + * Cheap boolean check: does the selected account group hold any + * non-excluded positive-fiat-balance asset + */ +export const selectHasEligibleSwapSource = createSelector( + [ + selectAssetsBySelectedAccountGroup, + (_state: RootState, excludedChainId: string | undefined) => excludedChainId, + ( + _state: RootState, + _excludedChainId: string | undefined, + excludedAddress: string | undefined, + ) => excludedAddress, + ], + (assetsByChain, excludedChainId, excludedAddress): boolean => { + for (const chainAssets of Object.values(assetsByChain)) { + for (const asset of chainAssets) { + if ((asset.fiat?.balance ?? 0) <= 0) continue; + + const isExcludedToken = + asset.chainId === excludedChainId && + excludedAddress !== undefined && + asset.assetId.toLowerCase() === excludedAddress.toLowerCase(); + + if (!isExcludedToken) { + return true; + } + } + } + + return false; + }, +); + const EMPTY_ACCOUNT_GROUP_ASSETS: AccountGroupAssets = {}; const selectAllAssetsGrouped = createDeepEqualSelector(