diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 09a4894375d..278278601a0 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -314,7 +314,7 @@ const AssetOverview: React.FC = ({ dispatch(newAssetTransaction(asset)); } - navigateToSendPage(InitSendLocation.AssetOverview, asset); + navigateToSendPage({ location: InitSendLocation.AssetOverview, asset }); }; const onBuy = () => { diff --git a/app/components/UI/CollectibleModal/CollectibleModal.tsx b/app/components/UI/CollectibleModal/CollectibleModal.tsx index e2a8f33f482..c03db824998 100644 --- a/app/components/UI/CollectibleModal/CollectibleModal.tsx +++ b/app/components/UI/CollectibleModal/CollectibleModal.tsx @@ -87,7 +87,10 @@ const CollectibleModal = () => { const onSend = useCallback(async () => { dispatch(newAssetTransaction({ contractName, ...collectible })); - navigateToSendPage(InitSendLocation.CollectibleModal, collectible); + navigateToSendPage({ + location: InitSendLocation.CollectibleModal, + asset: collectible, + }); }, [contractName, collectible, dispatch, navigateToSendPage]); const isTradable = useCallback( diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx index fd9451c3956..70cd654ef49 100644 --- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx +++ b/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.tsx @@ -1,33 +1,7 @@ -import React, { useCallback } from 'react'; -import { - View, - TouchableOpacity, - Pressable, - Keyboard, - TextInput, - Platform, -} from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useStyles } from '../../../../../component-library/hooks'; -import { - Box, - BoxFlexDirection, - BoxAlignItems, -} from '@metamask/design-system-react-native'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import Icon, { - IconName, - IconSize, - IconColor, -} from '../../../../../component-library/components/Icons/Icon'; -import Text, { - TextVariant, - TextColor, -} from '../../../../../component-library/components/Texts/Text'; +import React from 'react'; import { strings } from '../../../../../../locales/i18n'; -import { useTheme } from '../../../../../util/theme'; +import ListHeaderWithSearch from '../../../shared/ListHeaderWithSearch'; import type { PerpsMarketListHeaderProps } from './PerpsMarketListHeader.types'; -import styleSheet from './PerpsMarketListHeader.styles'; /** * PerpsMarketListHeader Component @@ -59,109 +33,13 @@ import styleSheet from './PerpsMarketListHeader.styles'; * /> * ``` */ -const PerpsMarketListHeader: React.FC = ({ - title, - isSearchVisible = false, - searchQuery = '', - onSearchQueryChange, - onSearchClear: _onSearchClear, // Not used - clear icon removed - onBack, - onSearchToggle, - testID, -}) => { - const { styles } = useStyles(styleSheet, {}); - const tw = useTailwind(); - const { colors } = useTheme(); - const navigation = useNavigation(); - - // Default back handler - const defaultHandleBack = useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack(); - } - }, [navigation]); - - // Use custom handler if provided, otherwise use default - const handleBack = onBack || defaultHandleBack; - - return ( - Keyboard.dismiss()} - testID={testID} - > - {isSearchVisible ? ( - - {/* Search Bar - Replaces back button and title */} - - - - - {/* Cancel Button */} - - - {strings('perps.cancel')} - - - - ) : ( - - {/* Back Button */} - - - - - {/* Title */} - - - {title || strings('perps.title')} - - - - {/* Search Toggle Button */} - - - - - - - )} - - ); -}; +const PerpsMarketListHeader: React.FC = (props) => ( + +); export default PerpsMarketListHeader; diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index bdbe4ab2c19..89da8e65ebc 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -594,6 +594,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1772,6 +1773,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -2786,6 +2788,7 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -5636,6 +5639,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -6518,6 +6522,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -7532,6 +7537,7 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -8959,6 +8965,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -9750,6 +9757,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -10764,6 +10772,7 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -12191,6 +12200,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -13326,6 +13336,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -14340,6 +14351,7 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -15007,6 +15019,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -16064,6 +16077,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -17078,6 +17092,7 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -18505,6 +18520,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -19260,6 +19276,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -20274,6 +20291,7 @@ exports[`BuildQuote View Regions data renders the loading page when regions are [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -20941,6 +20959,7 @@ exports[`BuildQuote View renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -22119,6 +22138,7 @@ exports[`BuildQuote View renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -23133,6 +23153,7 @@ exports[`BuildQuote View renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -23703,6 +23724,7 @@ exports[`BuildQuote View renders correctly 2`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -24797,6 +24819,7 @@ exports[`BuildQuote View renders correctly 2`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -25875,6 +25898,7 @@ exports[`BuildQuote View renders correctly 2`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index 59e706e47db..68922e4a7b5 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -482,6 +482,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1418,6 +1419,7 @@ exports[`OrderDetails renders a cancelled order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1975,6 +1977,7 @@ exports[`OrderDetails renders a completed order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -2929,6 +2932,7 @@ exports[`OrderDetails renders a completed order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -3486,6 +3490,7 @@ exports[`OrderDetails renders a created order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -4455,6 +4460,7 @@ exports[`OrderDetails renders a created order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -4960,6 +4966,7 @@ exports[`OrderDetails renders a failed order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -5896,6 +5903,7 @@ exports[`OrderDetails renders a failed order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -6453,6 +6461,7 @@ exports[`OrderDetails renders a pending order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -7422,6 +7431,7 @@ exports[`OrderDetails renders a pending order 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -9049,6 +9059,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -10033,6 +10044,7 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -10603,6 +10615,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -11603,6 +11616,7 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -12160,6 +12174,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -13129,6 +13144,7 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -13634,6 +13650,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -14618,6 +14635,7 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 0ed4a61df6e..1436c28f2f0 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -1365,6 +1365,7 @@ exports[`Quotes custom action renders correctly after animation with the recomme [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3697,6 +3698,7 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3785,6 +3787,7 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -5585,6 +5588,7 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap index 622768e4403..05edc904e23 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap @@ -461,6 +461,7 @@ exports[`SendTransaction View renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -715,6 +716,7 @@ exports[`SendTransaction View renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1278,6 +1280,7 @@ exports[`SendTransaction View renders correctly for custom action payment method [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -1450,6 +1453,7 @@ exports[`SendTransaction View renders correctly for custom action payment method [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -2013,6 +2017,7 @@ exports[`SendTransaction View renders correctly for token 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -2271,6 +2276,7 @@ exports[`SendTransaction View renders correctly for token 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap index 08989b72bb8..5c0ee199447 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/ActivationKeyForm.test.tsx.snap @@ -464,6 +464,7 @@ exports[`AddActivationKey renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap index 9611833b41a..7d0c073b2f6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Settings/__snapshots__/Settings.test.tsx.snap @@ -478,6 +478,7 @@ exports[`Settings Activation Keys renders correctly when is loading 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1696,6 +1697,7 @@ exports[`Settings Activation Keys renders correctly when there are no keys 1`] = [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -2472,6 +2474,7 @@ exports[`Settings Region renders correctly when region is not set 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -3077,6 +3080,7 @@ exports[`Settings Region renders correctly when region is set 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -3720,6 +3724,7 @@ exports[`Settings renders correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -4363,6 +4368,7 @@ exports[`Settings renders correctly for internal builds 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx b/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx index 8c68890df23..0ead307f810 100644 --- a/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx +++ b/app/components/UI/Ramp/Aggregator/components/ScreenLayout.tsx @@ -23,6 +23,7 @@ const createStyles = (colors: Colors) => }, content: { padding: 15, + paddingHorizontal: 16, }, grow: { flex: 1, diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap index fe54a783c20..1c5aa3d7a5b 100644 --- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap @@ -558,6 +558,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -631,6 +632,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap index 61f845f6f05..54252d68ec3 100644 --- a/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BankDetails/__snapshots__/BankDetails.test.tsx.snap @@ -580,6 +580,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -913,6 +914,7 @@ exports[`BankDetails Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1690,6 +1692,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2236,6 +2239,7 @@ exports[`BankDetails Component render matches snapshot with bank info shown 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap index 159a08e0341..0ecf3cbe46b 100644 --- a/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BasicInfo/__snapshots__/BasicInfo.test.tsx.snap @@ -589,6 +589,7 @@ exports[`BasicInfo Component navigates to address page when form is valid and co [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1422,6 +1423,7 @@ exports[`BasicInfo Component navigates to address page when form is valid and co [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2175,6 +2177,7 @@ exports[`BasicInfo Component passes regions to DepositPhoneField component 1`] = [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -3008,6 +3011,7 @@ exports[`BasicInfo Component passes regions to DepositPhoneField component 1`] = [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3761,6 +3765,7 @@ exports[`BasicInfo Component prefills form data when previousFormData is provide [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -4594,6 +4599,7 @@ exports[`BasicInfo Component prefills form data when previousFormData is provide [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -5347,6 +5353,7 @@ exports[`BasicInfo Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -6180,6 +6187,7 @@ exports[`BasicInfo Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -6933,6 +6941,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -7826,6 +7835,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -8579,6 +8589,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -9457,6 +9468,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -10210,6 +10222,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -10921,6 +10934,7 @@ exports[`BasicInfo Component snapshot matches validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 519e4f78cc5..5f5a98b57b9 100644 --- a/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -558,6 +558,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1813,6 +1814,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -2432,6 +2434,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3687,6 +3690,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -4306,6 +4310,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -5561,6 +5566,7 @@ exports[`BuildQuote Component Continue button functionality displays error when [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -6180,6 +6186,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -7374,6 +7381,7 @@ exports[`BuildQuote Component Keypad Functionality displays converted token amou [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -7993,6 +8001,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -9187,6 +9196,7 @@ exports[`BuildQuote Component Keypad Functionality updates amount when keypad is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -9805,6 +9815,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -10999,6 +11010,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -11618,6 +11630,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -12905,6 +12918,7 @@ exports[`BuildQuote Component Payment Method Selection does not open payment met [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -13524,6 +13538,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -14673,6 +14688,7 @@ exports[`BuildQuote Component Payment Method Selection does not show the duratio [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -15292,6 +15308,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -16486,6 +16503,7 @@ exports[`BuildQuote Component Payment Method Selection shows the right duration [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -17105,6 +17123,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -18299,6 +18318,7 @@ exports[`BuildQuote Component Region Selection displays EUR currency when select [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -18918,6 +18938,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -20112,6 +20133,7 @@ exports[`BuildQuote Component Region Selection displays default US region on ini [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -20731,6 +20753,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -21925,6 +21948,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -22544,6 +22568,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -23831,6 +23856,7 @@ exports[`BuildQuote Component Region Selection does not open region modal when r [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -24450,6 +24476,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -25642,6 +25669,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -26261,6 +26289,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -27546,6 +27575,7 @@ exports[`BuildQuote Component Token Selection does not open token modal when cry [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -28165,6 +28195,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -29452,6 +29483,7 @@ exports[`BuildQuote Component User Details Error displays user details error ale [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -30071,6 +30103,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -31265,6 +31298,7 @@ exports[`BuildQuote Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap index 24547746e00..26110bd3729 100644 --- a/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/DepositOrderDetails/__snapshots__/DepositOrderDetails.test.tsx.snap @@ -68,6 +68,7 @@ exports[`DepositOrderDetails Component renders an error screen if a CREATED orde [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -242,6 +243,7 @@ exports[`DepositOrderDetails Component renders error state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -761,6 +763,7 @@ exports[`DepositOrderDetails Component renders loading state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -867,6 +870,7 @@ exports[`DepositOrderDetails Component renders processing state correctly 1`] = [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1411,6 +1415,7 @@ exports[`DepositOrderDetails Component renders success state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap index 2537a317f36..10553582273 100644 --- a/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/EnterAddress/__snapshots__/EnterAddress.test.tsx.snap @@ -589,6 +589,7 @@ exports[`EnterAddress Component displays form validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -1514,6 +1515,7 @@ exports[`EnterAddress Component displays form validation errors when continue is [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2268,6 +2270,7 @@ exports[`EnterAddress Component prefills form data when previousFormData is prov [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -3125,6 +3128,7 @@ exports[`EnterAddress Component prefills form data when previousFormData is prov [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3879,6 +3883,7 @@ exports[`EnterAddress Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -4744,6 +4749,7 @@ exports[`EnterAddress Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -5498,6 +5504,7 @@ exports[`EnterAddress Component shows text input for state when region is not US [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -6384,6 +6391,7 @@ exports[`EnterAddress Component shows text input for state when region is not US [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap index 47d2c1851a4..a3d14868660 100644 --- a/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/EnterEmail/__snapshots__/EnterEmail.test.tsx.snap @@ -558,6 +558,7 @@ exports[`EnterEmail Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -790,6 +791,7 @@ exports[`EnterEmail Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1420,6 +1422,7 @@ exports[`EnterEmail Component renders error message snapshot when API call fails [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -1666,6 +1669,7 @@ exports[`EnterEmail Component renders error message snapshot when API call fails [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2296,6 +2300,7 @@ exports[`EnterEmail Component renders loading state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -2528,6 +2533,7 @@ exports[`EnterEmail Component renders loading state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3158,6 +3164,7 @@ exports[`EnterEmail Component renders validation error snapshot invalid email 1` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -3404,6 +3411,7 @@ exports[`EnterEmail Component renders validation error snapshot invalid email 1` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap index ae04ee2cc23..fd94bce5143 100644 --- a/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/KycProcessing/__snapshots__/KycProcessing.test.tsx.snap @@ -558,6 +558,7 @@ exports[`KycProcessing Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -714,6 +715,7 @@ exports[`KycProcessing Component render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1306,6 +1308,7 @@ exports[`KycProcessing Component renders approved state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -1484,6 +1487,7 @@ exports[`KycProcessing Component renders approved state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2112,6 +2116,7 @@ exports[`KycProcessing Component renders error state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -2276,6 +2281,7 @@ exports[`KycProcessing Component renders error state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2904,6 +2910,7 @@ exports[`KycProcessing Component renders loading state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -3060,6 +3067,7 @@ exports[`KycProcessing Component renders loading state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3652,6 +3660,7 @@ exports[`KycProcessing Component renders pending forms state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -3816,6 +3825,7 @@ exports[`KycProcessing Component renders pending forms state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -4444,6 +4454,7 @@ exports[`KycProcessing Component renders rejected state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -4608,6 +4619,7 @@ exports[`KycProcessing Component renders rejected state snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap index e11294acbfe..f7e9984c555 100644 --- a/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/Modals/WebviewModal/__snapshots__/WebviewModal.test.tsx.snap @@ -1142,6 +1142,7 @@ exports[`WebviewModal Component should display error view when webview HTTP erro [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap index 019912ec739..3f999874f4a 100644 --- a/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/OrderProcessing/__snapshots__/OrderProcessing.test.tsx.snap @@ -42,6 +42,7 @@ exports[`OrderProcessing Component renders created state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -526,6 +527,7 @@ exports[`OrderProcessing Component renders created state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -635,6 +637,7 @@ exports[`OrderProcessing Component renders error state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1115,6 +1118,7 @@ exports[`OrderProcessing Component renders error state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1313,6 +1317,7 @@ exports[`OrderProcessing Component renders processing state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1797,6 +1802,7 @@ exports[`OrderProcessing Component renders processing state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, @@ -1906,6 +1912,7 @@ exports[`OrderProcessing Component renders success state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2386,6 +2393,7 @@ exports[`OrderProcessing Component renders success state correctly 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, undefined, diff --git a/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap index 35c685a6e42..159b1b8af96 100644 --- a/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/OtpCode/__snapshots__/OtpCode.test.tsx.snap @@ -353,6 +353,7 @@ exports[`OtpCode Screen calls resendOtp when resend link is clicked and properly [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -766,6 +767,7 @@ exports[`OtpCode Screen calls resendOtp when resend link is clicked and properly [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -1193,6 +1195,7 @@ exports[`OtpCode Screen render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -1626,6 +1629,7 @@ exports[`OtpCode Screen render matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2053,6 +2057,7 @@ exports[`OtpCode Screen renders cooldown timer snapshot after resending OTP 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -2466,6 +2471,7 @@ exports[`OtpCode Screen renders cooldown timer snapshot after resending OTP 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -2893,6 +2899,7 @@ exports[`OtpCode Screen renders error snapshot when API call fails 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -3352,6 +3359,7 @@ exports[`OtpCode Screen renders error snapshot when API call fails 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -3778,6 +3786,7 @@ exports[`OtpCode Screen renders resend error snapshot when resend fails 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -4191,6 +4200,7 @@ exports[`OtpCode Screen renders resend error snapshot when resend fails 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap index bc6dd6baba8..7d3f1ae59f9 100644 --- a/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/VerifyIdentity/__snapshots__/VerifyIdentity.test.tsx.snap @@ -361,6 +361,7 @@ exports[`VerifyIdentity Component renders verify identity screen with all conten [ { "padding": 15, + "paddingHorizontal": 16, }, { "flex": 1, @@ -486,6 +487,7 @@ exports[`VerifyIdentity Component renders verify identity screen with all conten [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap b/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap index 64ab68cc54f..ca454810f97 100644 --- a/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/components/ErrorView/__snapshots__/ErrorView.test.tsx.snap @@ -343,6 +343,7 @@ exports[`ErrorView Component renders with all props and matches snapshot 1`] = ` [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { @@ -804,6 +805,7 @@ exports[`ErrorView Component renders with default props and matches snapshot 1`] [ { "padding": 15, + "paddingHorizontal": 16, }, undefined, { diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx index a5e80abaa32..1eed5f7fc3e 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.test.tsx @@ -60,62 +60,38 @@ describe('TokenNetworkFilterBar', () => { expect(toJSON()).toMatchSnapshot(); }); - it('renders correctly with partial networks selected', () => { - const { toJSON } = render( - , - ); - - expect(toJSON()).toMatchSnapshot(); - }); - - describe('handleNetworkPress', () => { - it('sets single network when all networks are selected', () => { - const { getByText } = render( - , - ); - - fireEvent.press(getByText('Ethereum')); - - expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']); - }); - - it('removes network from filter when network is currently selected', () => { + describe('handleAllPress', () => { + it('sets filter to null when clicking "All" button', () => { const { getByText } = render( , ); - fireEvent.press(getByText('Ethereum')); + fireEvent.press(getByText('All')); - expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:10']); + expect(mockSetNetworkFilter).toHaveBeenCalledWith(null); }); + }); - it('sets filter to empty array when deselecting last selected network', () => { + describe('handleNetworkPress', () => { + it('sets single network when all networks are selected', () => { const { getByText } = render( , ); fireEvent.press(getByText('Ethereum')); - expect(mockSetNetworkFilter).toHaveBeenCalledWith([]); + expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']); }); - it('adds network to filter when network is not currently selected', () => { + it('replaces selected network when clicking different network', () => { const { getByText } = render( { fireEvent.press(getByText('Optimism')); - expect(mockSetNetworkFilter).toHaveBeenCalledWith([ - 'eip155:1', - 'eip155:10', - ]); + expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:10']); }); - it('sets filter to null when adding network results in all networks selected', () => { + it('sets same network when clicking already selected network', () => { const { getByText } = render( , ); - fireEvent.press(getByText('Polygon')); + fireEvent.press(getByText('Ethereum')); - expect(mockSetNetworkFilter).toHaveBeenCalledWith(null); + expect(mockSetNetworkFilter).toHaveBeenCalledWith(['eip155:1']); }); }); }); diff --git a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx index 5c04a6f53c4..b5203cd7e61 100644 --- a/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx +++ b/app/components/UI/Ramp/components/TokenNetworkFilterBar/TokenNetworkFilterBar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { CaipChainId } from '@metamask/utils'; import { ScrollView } from 'react-native-gesture-handler'; @@ -16,7 +16,6 @@ import Text, { import styleSheet from './TokenNetworkFilterBar.styles'; import { useStyles } from '../../../../hooks/useStyles'; -import { excludeFromArray } from '../../Deposit/utils'; import { useTokenNetworkInfo } from '../../hooks/useTokenNetworkInfo'; import { strings } from '../../../../../../locales/i18n'; @@ -39,27 +38,17 @@ function TokenNetworkFilterBar({ networkFilter.length === 0 || networkFilter.length === networks.length; - const handleAllPress = () => { + const handleAllPress = useCallback(() => { setNetworkFilter(null); - }; + }, [setNetworkFilter]); - const handleNetworkPress = (chainId: CaipChainId) => { - if (isAllSelected) { + const handleNetworkPress = useCallback( + (chainId: CaipChainId) => { + // Radio button behavior: always set to single selection setNetworkFilter([chainId]); - return; - } - - const currentFilter = networkFilter || []; - const isSelected = currentFilter.includes(chainId); - - if (isSelected) { - const newFilter = excludeFromArray(currentFilter, chainId); - setNetworkFilter(newFilter.length === networks.length ? null : newFilter); - } else { - const newFilter = [...currentFilter, chainId]; - setNetworkFilter(newFilter.length === networks.length ? null : newFilter); - } - }; + }, + [setNetworkFilter], + ); return ( `; -exports[`TokenNetworkFilterBar renders correctly with partial networks selected 1`] = ` - - - - - All - - - - - - - - Ethereum - - - - - - - - Optimism - - - - - - - - Polygon - - - - -`; - exports[`TokenNetworkFilterBar renders correctly with single network selected 1`] = ` + + // For transferFrom transactions, prioritize decoding from data when available + // transferInformation is used as fallback since the data may only contain the function signature + let addressFrom, addressTo, tokenId; + + // Try to decode from data first if it's complete (4 bytes selector + 3×32 bytes params = 202 chars) + if (data && data.length < 202 && transferInformation) { + // Data is truncated, use transferInformation as fallback + tokenId = + transferInformation.tokenId || + transferInformation.tokenAmount || + transferInformation.value; + // For direction, use txParams.from as the sender + // Note: txParams.to is the contract, not the recipient, so we can't reliably set addressTo + addressFrom = txParams.from; + // We can't determine the actual recipient from truncated data + // Use txParams for direction logic, but this won't show the correct recipient in UI + addressTo = txParams.to; + } else { + // Data is complete or no transferInformation available - decode from data + [addressFrom, addressTo, tokenId] = decodeTransferData( + 'transferFrom', + data, + ); + } + const collectible = collectibleContracts?.find((collectible) => areAddressesEqual(collectible.address, to), ); let actionKey = args.actionKey; @@ -489,16 +511,36 @@ function decodeTransferFromTx(args) { } const totalGas = calculateTotalGas(txParams); - const renderCollectible = collectible?.symbol - ? `${strings('unit.token_id')}${tokenId} ${collectible?.symbol}` - : `${strings('unit.token_id')}${tokenId}`; + + // Handle cases where tokenId might be undefined or NaN + let renderCollectible; + if (collectible?.symbol) { + renderCollectible = + tokenId != null && !isNaN(Number(tokenId)) + ? `${strings('unit.token_id')}${tokenId} ${collectible.symbol}` + : collectible.symbol; + } else if (collectible?.name) { + renderCollectible = + tokenId != null && !isNaN(Number(tokenId)) + ? `${strings('unit.token_id')}${tokenId} ${collectible.name}` + : collectible.name; + } else { + // Fallback: show just the contract address or generic label + renderCollectible = + tokenId != null && !isNaN(Number(tokenId)) + ? `${strings('unit.token_id')}${tokenId}` + : strings('wallet.collectible'); + } const renderFrom = renderFullAddress(addressFrom); const renderTo = renderFullAddress(addressTo); const { SENT_COLLECTIBLE, RECEIVED_COLLECTIBLE } = TRANSACTION_TYPES; const transactionType = - renderFrom === selectedAddress ? SENT_COLLECTIBLE : RECEIVED_COLLECTIBLE; + (addressFrom?.toLowerCase() ?? txParams.from?.toLowerCase()) === + selectedAddress?.toLowerCase() + ? SENT_COLLECTIBLE + : RECEIVED_COLLECTIBLE; let transactionDetails = { renderFrom, @@ -539,12 +581,36 @@ function decodeTransferFromTx(args) { }; } + // Handle value display - avoid showing #undefined or #NaN + let displayValue; + let displayFiatValue; + + if (tokenId != null && !isNaN(Number(tokenId))) { + // We have a valid tokenId - show it + displayValue = `${strings('unit.token_id')}${tokenId}`; + displayFiatValue = collectible ? collectible.symbol : undefined; + } else if (collectible?.name) { + // Show collectible name + displayValue = collectible.name; + displayFiatValue = collectible.symbol; + } else if (collectible?.symbol) { + // Show collectible symbol + displayValue = collectible.symbol; + displayFiatValue = undefined; + } else { + // No tokenId or collectible info - show transaction fee + const totalGasFee = renderFromWei(totalGas); + displayValue = + totalGasFee === '0' ? `0 ${ticker}` : `${totalGasFee} ${ticker}`; + displayFiatValue = weiToFiat(totalGas, conversionRate, currentCurrency); + } + const transactionElement = { renderTo, renderFrom, actionKey, - value: `${strings('unit.token_id')}${tokenId}`, - fiatValue: collectible ? collectible.symbol : undefined, + value: displayValue, + fiatValue: displayFiatValue, transactionType, }; diff --git a/app/components/UI/TransactionElement/utils.test.js b/app/components/UI/TransactionElement/utils.test.js index 367137897b4..8bd3c1bb04e 100644 --- a/app/components/UI/TransactionElement/utils.test.js +++ b/app/components/UI/TransactionElement/utils.test.js @@ -196,17 +196,19 @@ describe('Transaction Element Utils', () => { it('if incoming transfer', async () => { // Arrange + const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const contractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; const args = { tx: { txParams: { - to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + to: contractAddress, // Token contract address (not recipient) from: '0x1440ec793ae50fa046b95bfeca5af475b6003f9e', value: '52daf0', }, transferInformation: { symbol: 'USDT', decimals: 6, - contractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + contractAddress, }, hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', isTransfer: true, @@ -217,7 +219,7 @@ describe('Transaction Element Utils', () => { totalGas: '0x64', actionKey: 'key', primaryCurrency: 'ETH', - selectedAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + selectedAddress, ticker: 'ETH', txChainId: '0x1', }; @@ -254,17 +256,19 @@ describe('Transaction Element Utils', () => { it('if large value', async () => { // Arrange + const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const contractAddress = '0xdac17f958d2ee523a2206206994597c13d831ec7'; const args = { tx: { txParams: { - to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + to: contractAddress, // Token contract address (not recipient) from: '0x1440ec793ae50fa046b95bfeca5af475b6003f9e', value: '3B9ACA00', // 1000000000 in decimal }, transferInformation: { symbol: 'USDT', decimals: 6, - contractAddress: '0xdac17f958d2ee523a2206206994597c13d831ec7', + contractAddress, }, hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', isTransfer: true, @@ -275,7 +279,7 @@ describe('Transaction Element Utils', () => { totalGas: '0x64', actionKey: 'key', primaryCurrency: 'ETH', - selectedAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + selectedAddress, ticker: 'ETH', txChainId: '0x1', }; @@ -447,5 +451,261 @@ describe('Transaction Element Utils', () => { txChainId: '0x89', }); }); + + it('sets SENT_COLLECTIBLE type when user is sender', async () => { + const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + from: selectedAddress, + data: '0x23b872dd', + gas: '0x5208', + }, + transferInformation: { + tokenId: '123', + contractAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Sent Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [], + }; + + const [transactionElement, transactionDetails] = + await decodeTransaction(args); + + expect(transactionElement.transactionType).toBe( + TRANSACTION_TYPES.SENT_COLLECTIBLE, + ); + expect(transactionDetails.transactionType).toBe( + TRANSACTION_TYPES.SENT_COLLECTIBLE, + ); + }); + + it('sets RECEIVED_COLLECTIBLE type when user is receiver', async () => { + const selectedAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const senderAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const contractAddress = '0xabcdef1234567890abcdef1234567890abcdef12'; + + // Complete transferFrom data with actual addresses encoded + const completeData = + '0x23b872dd' + // transferFrom signature + '000000000000000000000000' + + senderAddress.slice(2) + // from (sender) + '000000000000000000000000' + + selectedAddress.slice(2) + // to (recipient - the selected address) + '0000000000000000000000000000000000000000000000000000000000000456'; // tokenId (456 in hex) + + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: contractAddress, // Contract address, not recipient + from: senderAddress, + data: completeData, // Complete data with recipient encoded + gas: '0x5208', + }, + transferInformation: { + tokenId: '456', + contractAddress, + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Received Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [], + }; + + const [transactionElement, transactionDetails] = + await decodeTransaction(args); + + expect(transactionElement.transactionType).toBe( + TRANSACTION_TYPES.RECEIVED_COLLECTIBLE, + ); + expect(transactionDetails.transactionType).toBe( + TRANSACTION_TYPES.RECEIVED_COLLECTIBLE, + ); + }); + + it('sets SENT_COLLECTIBLE type with case-insensitive address comparison', async () => { + const selectedAddress = '0xABCDEF1234567890ABcdef1234567890abcdef12'; + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + from: '0xabcdef1234567890abcdef1234567890abcdef12', + data: '0x23b872dd', + gas: '0x5208', + }, + transferInformation: { + tokenId: '789', + contractAddress: '0x77648f1407986479fb1fa5cc3597084b5dbdb057', + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Sent Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [], + }; + + const [transactionElement] = await decodeTransaction(args); + + expect(transactionElement.transactionType).toBe( + TRANSACTION_TYPES.SENT_COLLECTIBLE, + ); + }); + + it('decodes recipient from complete data instead of using contract address', async () => { + const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const recipientAddress = '0x99999999999999999999999999999999999999aa'; + const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + + // Complete transferFrom data with actual addresses encoded + const completeData = + '0x23b872dd' + // transferFrom signature + '000000000000000000000000' + + selectedAddress.slice(2) + // from + '000000000000000000000000' + + recipientAddress.slice(2) + // to (actual recipient) + '0000000000000000000000000000000000000000000000000000000000000123'; // tokenId + + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: contractAddress, // This is the contract, not the recipient + from: selectedAddress, + data: completeData, + gas: '0x5208', + }, + transferInformation: { + tokenId: '291', + contractAddress, + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Sent Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [], + }; + + const [transactionElement] = await decodeTransaction(args); + + // Should decode recipient from data, not use txParams.to (contract address) + expect(transactionElement.renderTo).toContain('9999'); // Should show recipient, not contract + expect(transactionElement.renderTo).not.toContain('7764'); // Should NOT show contract address + expect(transactionElement.transactionType).toBe( + TRANSACTION_TYPES.SENT_COLLECTIBLE, + ); + }); + + it('uses transferInformation as fallback when data is truncated', async () => { + const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: contractAddress, + from: selectedAddress, + data: '0x23b872dd', // Only function signature - truncated data + gas: '0x5208', + }, + transferInformation: { + tokenId: '456', + contractAddress, + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Sent Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [], + }; + + const [transactionElement] = await decodeTransaction(args); + + // With truncated data, we fall back to transferInformation + // Transaction type should still be determined correctly based on txParams.from + expect(transactionElement.transactionType).toBe( + TRANSACTION_TYPES.SENT_COLLECTIBLE, + ); + expect(transactionElement.value).toContain('456'); // Should use tokenId from transferInformation + }); + + it('displays token ID 0 correctly', async () => { + const selectedAddress = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const contractAddress = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const args = { + tx: { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: contractAddress, + from: selectedAddress, + data: '0x23b872dd', + gas: '0x5208', + }, + transferInformation: { + tokenId: '0', + contractAddress, + }, + hash: '0x942d7843454266b81bf631022aa5f3f944691731b62d67c4e80c4bb5740058bb', + }, + currentCurrency: 'usd', + conversionRate: 1, + totalGas: '0x5208', + actionKey: 'Sent Collectible', + primaryCurrency: 'ETH', + selectedAddress, + ticker: 'ETH', + txChainId: '0x1', + collectibleContracts: [ + { + address: contractAddress, + name: 'TestNFT', + symbol: 'TNFT', + }, + ], + }; + + const [transactionElement] = await decodeTransaction(args); + + expect(transactionElement.value).toContain('#0'); + expect(transactionElement.fiatValue).toBe('TNFT'); + }); }); }); diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx new file mode 100644 index 00000000000..a7265641f71 --- /dev/null +++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import TrendingListHeader from './TrendingListHeader'; + +// Mock navigation +const mockGoBack = jest.fn(); +const mockCanGoBack = jest.fn().mockReturnValue(true); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + goBack: mockGoBack, + canGoBack: mockCanGoBack, + }), +})); + +// Mock tailwind hook +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = Object.assign((..._args: unknown[]) => ({}), { + style: (..._args: unknown[]) => ({}), + }); + + return { + useTailwind: () => tw, + }; +}); +describe('TrendingListHeader', () => { + const defaultProps = { + title: 'Trending Tokens', + isSearchVisible: false, + searchQuery: '', + onSearchQueryChange: jest.fn(), + onBack: jest.fn(), + onSearchToggle: jest.fn(), + testID: 'trending-list-header', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders back button and title when search is not visible', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const backButton = getByTestId('trending-list-header-back-button'); + const searchToggle = getByTestId('trending-list-header-search-toggle'); + + expect(backButton).toBeOnTheScreen(); + expect(searchToggle).toBeOnTheScreen(); + expect(queryByTestId('trending-list-header-search-bar')).toBeNull(); + }); + + it('calls onBack handler when back button is pressed', () => { + const onBack = jest.fn(); + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('trending-list-header-back-button'); + + fireEvent.press(backButton); + + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it('navigates back with default handler when onBack is not provided', () => { + const { getByTestId } = render( + , + ); + + const backButton = getByTestId('trending-list-header-back-button'); + + fireEvent.press(backButton); + + expect(mockCanGoBack).toHaveBeenCalledTimes(1); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('renders search bar when search is visible', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const searchBar = getByTestId('trending-list-header-search-bar'); + const searchClose = getByTestId('trending-list-header-search-close'); + + expect(searchBar).toBeOnTheScreen(); + expect(searchClose).toBeOnTheScreen(); + expect(queryByTestId('trending-list-header-back-button')).toBeNull(); + }); + + it('calls onSearchQueryChange when search text changes', () => { + const onSearchQueryChange = jest.fn(); + + const { getByTestId } = render( + , + ); + + const searchBar = getByTestId('trending-list-header-search-bar'); + + fireEvent.changeText(searchBar, 'eth'); + + expect(onSearchQueryChange).toHaveBeenCalledWith('eth'); + }); + + it('calls onSearchToggle when search toggle button is pressed', () => { + const onSearchToggle = jest.fn(); + const { getByTestId } = render( + , + ); + + const searchToggle = getByTestId('trending-list-header-search-toggle'); + + fireEvent.press(searchToggle); + + expect(onSearchToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx new file mode 100644 index 00000000000..e22e08d985c --- /dev/null +++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { strings } from '../../../../../../locales/i18n'; +import ListHeaderWithSearch from '../../../shared/ListHeaderWithSearch'; +import type { TrendingListHeaderProps } from './TrendingListHeader.types'; + +/** + * TrendingListHeader Component + * + * Header component for Trending Tokens List view with back button, + * title, and search toggle functionality + * + * Features: + * - Back button with default or custom navigation handler + * - Centered title with custom text support + * - Search toggle button that changes icon based on visibility + * - Keyboard dismiss on header press + * + * @example + * ```tsx + * + * ``` + * + * @example Custom back handler + * ```tsx + * + * ``` + */ +const TrendingListHeader: React.FC = (props) => ( + +); + +export default TrendingListHeader; diff --git a/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts new file mode 100644 index 00000000000..1c6fa29105d --- /dev/null +++ b/app/components/UI/Trending/components/TrendingListHeader/TrendingListHeader.types.ts @@ -0,0 +1,47 @@ +/** + * Props for TrendingListHeader component + */ +export interface TrendingListHeaderProps { + /** + * Header title text + * @default strings('trending.trending_tokens') + */ + title?: string; + + /** + * Whether search bar is currently visible + * @default false + */ + isSearchVisible?: boolean; + + /** + * Search query value (required when isSearchVisible is true) + */ + searchQuery?: string; + + /** + * Callback when search query changes + */ + onSearchQueryChange?: (query: string) => void; + + /** + * Callback when search clear button is pressed + */ + onSearchClear?: () => void; + + /** + * Callback when back button is pressed + * If not provided, uses default navigation.goBack() + */ + onBack?: () => void; + + /** + * Callback when search toggle button is pressed + */ + onSearchToggle?: () => void; + + /** + * Test ID for the header container + */ + testID?: string; +} diff --git a/app/components/UI/Trending/components/TrendingListHeader/index.ts b/app/components/UI/Trending/components/TrendingListHeader/index.ts new file mode 100644 index 00000000000..ecab283840b --- /dev/null +++ b/app/components/UI/Trending/components/TrendingListHeader/index.ts @@ -0,0 +1,2 @@ +export { default as TrendingListHeader } from './TrendingListHeader'; +export type { TrendingListHeaderProps } from './TrendingListHeader.types'; diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx index 92457a3a02e..7c3e6d69701 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.test.tsx @@ -38,7 +38,7 @@ const mockNetworks: ProcessedNetwork[] = [ const mockUsePopularNetworks = jest.fn(() => mockNetworks); -jest.mock('../../../../hooks/usePopularNetworks', () => ({ +jest.mock('../../hooks/usePopularNetworks', () => ({ usePopularNetworks: () => mockUsePopularNetworks(), })); @@ -227,9 +227,9 @@ describe('TrendingTokenNetworkBottomSheet', () => { false, ); - expect(getByText('Networks')).toBeTruthy(); - expect(getByText('All networks')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('Networks')).toBeOnTheScreen(); + expect(getByText('All networks')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('renders all network options', () => { @@ -239,9 +239,9 @@ describe('TrendingTokenNetworkBottomSheet', () => { false, ); - expect(getByText('All networks')).toBeTruthy(); - expect(getByText('Ethereum Mainnet')).toBeTruthy(); - expect(getByText('Polygon')).toBeTruthy(); + expect(getByText('All networks')).toBeOnTheScreen(); + expect(getByText('Ethereum Mainnet')).toBeOnTheScreen(); + expect(getByText('Polygon')).toBeOnTheScreen(); }); it('calls onNetworkSelect with null when "All networks" is pressed', () => { @@ -347,8 +347,8 @@ describe('TrendingTokenNetworkBottomSheet', () => { false, ); - expect(getByText('Ethereum Mainnet')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('Ethereum Mainnet')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('displays check icon for "All networks" when selected', () => { @@ -358,8 +358,8 @@ describe('TrendingTokenNetworkBottomSheet', () => { false, ); - expect(getByText('All networks')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('All networks')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('renders network avatars with correct props', () => { @@ -370,13 +370,13 @@ describe('TrendingTokenNetworkBottomSheet', () => { ); const ethereumAvatar = getByTestId('avatar-Ethereum Mainnet'); - expect(ethereumAvatar).toBeTruthy(); + expect(ethereumAvatar).toBeOnTheScreen(); expect(ethereumAvatar.props['data-image-source']).toEqual({ uri: 'https://example.com/ethereum.png', }); const polygonAvatar = getByTestId('avatar-Polygon'); - expect(polygonAvatar).toBeTruthy(); + expect(polygonAvatar).toBeOnTheScreen(); expect(polygonAvatar.props['data-image-source']).toEqual({ uri: 'https://example.com/polygon.png', }); @@ -389,7 +389,7 @@ describe('TrendingTokenNetworkBottomSheet', () => { false, ); - expect(getByTestId('icon-Global')).toBeTruthy(); + expect(getByTestId('icon-Global')).toBeOnTheScreen(); }); it('does not render when isVisible is false', () => { diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx index 02e620544e0..d2cb9cece47 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenNetworkBottomSheet.tsx @@ -19,7 +19,7 @@ import Avatar, { import { strings } from '../../../../../../locales/i18n'; import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { CaipChainId } from '@metamask/utils'; -import { usePopularNetworks } from '../../../../hooks/usePopularNetworks'; +import { usePopularNetworks } from '../../hooks/usePopularNetworks'; export enum NetworkOption { AllNetworks = 'all', @@ -32,6 +32,7 @@ const EXCLUDED_NETWORKS: CaipChainId[] = [ 'eip155:11297108109', // Palm 'eip155:999', // Hyper EVM 'eip155:143', // Monad + 'bip122:000000000019d6689c085ae165831e93', // btc mainnet ]; export interface TrendingTokenNetworkBottomSheetProps { diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx index a1aafc4cfa2..22597b83e72 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenPriceChangeBottomSheet.test.tsx @@ -111,9 +111,9 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { , ); - expect(getByText('Sort by')).toBeTruthy(); - expect(getByText('Price change')).toBeTruthy(); - expect(getByText('High to low')).toBeTruthy(); + expect(getByText('Sort by')).toBeOnTheScreen(); + expect(getByText('Price change')).toBeOnTheScreen(); + expect(getByText('High to low')).toBeOnTheScreen(); }); it('renders all sort options', () => { @@ -121,9 +121,9 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { , ); - expect(getByText('Price change')).toBeTruthy(); - expect(getByText('Volume')).toBeTruthy(); - expect(getByText('Market cap')).toBeTruthy(); + expect(getByText('Price change')).toBeOnTheScreen(); + expect(getByText('Volume')).toBeOnTheScreen(); + expect(getByText('Market cap')).toBeOnTheScreen(); }); it('renders Apply button', () => { @@ -131,8 +131,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { , ); - expect(getByTestId('apply-button')).toBeTruthy(); - expect(getByText('Apply')).toBeTruthy(); + expect(getByTestId('apply-button')).toBeOnTheScreen(); + expect(getByText('Apply')).toBeOnTheScreen(); }); it('displays "High to low" and down arrow for descending sort', () => { @@ -140,7 +140,7 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { , ); - expect(getByText('High to low')).toBeTruthy(); + expect(getByText('High to low')).toBeOnTheScreen(); }); it('toggles sort direction when same option is pressed', () => { @@ -149,13 +149,13 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { ); const priceChangeOption = getByText('Price change'); - expect(getByText('High to low')).toBeTruthy(); + expect(getByText('High to low')).toBeOnTheScreen(); const parent = priceChangeOption.parent; if (!parent) throw new Error('Parent element not found'); fireEvent.press(parent); - expect(getByText('Low to high')).toBeTruthy(); + expect(getByText('Low to high')).toBeOnTheScreen(); expect(queryByText('High to low')).toBeNull(); }); @@ -169,8 +169,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { if (!parent) throw new Error('Parent element not found'); fireEvent.press(parent); - expect(getByText('Volume')).toBeTruthy(); - expect(getByText('High to low')).toBeTruthy(); + expect(getByText('Volume')).toBeOnTheScreen(); + expect(getByText('High to low')).toBeOnTheScreen(); }); it('calls onPriceChangeSelect with correct values when Apply is pressed', () => { @@ -281,8 +281,8 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { />, ); - expect(getByText('Volume')).toBeTruthy(); - expect(getByText('Low to high')).toBeTruthy(); + expect(getByText('Volume')).toBeOnTheScreen(); + expect(getByText('Low to high')).toBeOnTheScreen(); }); it('calls onOpenBottomSheet when isVisible becomes true', () => { @@ -309,7 +309,7 @@ describe('TrendingTokenPriceChangeBottomSheet', () => { const parent = marketCapOption.parent; if (!parent) throw new Error('Parent element not found'); fireEvent.press(parent); - expect(getByText('Market cap')).toBeTruthy(); - expect(getByText('High to low')).toBeTruthy(); + expect(getByText('Market cap')).toBeOnTheScreen(); + expect(getByText('High to low')).toBeOnTheScreen(); }); }); diff --git a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx index b60bd5866df..2316f3c830c 100644 --- a/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensBottomSheet/TrendingTokenTimeBottomSheet.test.tsx @@ -123,9 +123,9 @@ describe('TrendingTokenTimeBottomSheet', () => { , ); - expect(getByText('Time')).toBeTruthy(); - expect(getByText('24 hours')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('Time')).toBeOnTheScreen(); + expect(getByText('24 hours')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('renders all time options', () => { @@ -133,10 +133,10 @@ describe('TrendingTokenTimeBottomSheet', () => { , ); - expect(getByText('24 hours')).toBeTruthy(); - expect(getByText('6 hours')).toBeTruthy(); - expect(getByText('1 hour')).toBeTruthy(); - expect(getByText('5 minutes')).toBeTruthy(); + expect(getByText('24 hours')).toBeOnTheScreen(); + expect(getByText('6 hours')).toBeOnTheScreen(); + expect(getByText('1 hour')).toBeOnTheScreen(); + expect(getByText('5 minutes')).toBeOnTheScreen(); }); it('calls onTimeSelect with correct sortBy when 24 hours is pressed', () => { @@ -274,8 +274,8 @@ describe('TrendingTokenTimeBottomSheet', () => { , ); - expect(getByText('24 hours')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('24 hours')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('does not render when isVisible is false', () => { @@ -295,8 +295,8 @@ describe('TrendingTokenTimeBottomSheet', () => { />, ); - expect(getByText('6 hours')).toBeTruthy(); - expect(getByTestId('icon-Check')).toBeTruthy(); + expect(getByText('6 hours')).toBeOnTheScreen(); + expect(getByTestId('icon-Check')).toBeOnTheScreen(); }); it('calls onOpenBottomSheet when isVisible becomes true', () => { diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx index b8c534d296d..2f6fdaf98a1 100644 --- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.test.tsx @@ -72,7 +72,7 @@ describe('TrendingTokensList', () => { />, ); - expect(getByTestId('trending-tokens-list')).toBeTruthy(); + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); }); it('renders multiple tokens', () => { @@ -101,7 +101,7 @@ describe('TrendingTokensList', () => { />, ); - expect(getByTestId('trending-tokens-list')).toBeTruthy(); + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); expect(getAllByTestId(/trending-token-row-item-/)).toHaveLength(3); }); }); diff --git a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx index 4147dcac707..ae890d714da 100644 --- a/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx +++ b/app/components/UI/Trending/components/TrendingTokensList/TrendingTokensList.tsx @@ -1,4 +1,5 @@ import React, { useCallback } from 'react'; +import { RefreshControl } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import { TrendingAsset } from '@metamask/assets-controllers'; import TrendingTokenRowItem from '../TrendingTokenRowItem/TrendingTokenRowItem'; @@ -13,6 +14,10 @@ export interface TrendingTokensListProps { * Selected time option to determine which price change field to display */ selectedTimeOption: TimeOption; + /** + * Refresh control for pull-to-refresh functionality + */ + refreshControl?: React.ReactElement; } /** @@ -22,7 +27,7 @@ export interface TrendingTokensListProps { * (renderItem and keyExtractor) to avoid recreating them on every render */ const TrendingTokensList: React.FC = React.memo( - ({ trendingTokens, selectedTimeOption }) => { + ({ trendingTokens, selectedTimeOption, refreshControl }) => { const renderItem = useCallback( ({ item }: { item: TrendingAsset }) => ( = React.memo( renderItem={renderItem} keyExtractor={keyExtractor} keyboardShouldPersistTaps="handled" + refreshControl={refreshControl as React.ReactElement} testID="trending-tokens-list" /> ); diff --git a/app/components/hooks/usePopularNetworks.ts b/app/components/UI/Trending/hooks/usePopularNetworks/index.ts similarity index 74% rename from app/components/hooks/usePopularNetworks.ts rename to app/components/UI/Trending/hooks/usePopularNetworks/index.ts index 0a8e2c487cf..77033408252 100644 --- a/app/components/hooks/usePopularNetworks.ts +++ b/app/components/UI/Trending/hooks/usePopularNetworks/index.ts @@ -2,11 +2,15 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; -import { getNetworkImageSource, isTestNet } from '../../util/networks'; -import { PopularList } from '../../util/networks/customNetworks'; import { BtcScope, SolScope } from '@metamask/keyring-api'; -import { selectNetworkConfigurationsByCaipChainId } from '../../selectors/networkController'; -import { ProcessedNetwork } from './useNetworksByNamespace/useNetworksByNamespace'; +import { + NetworkConfiguration, + RpcEndpointType, +} from '@metamask/network-controller'; +import { getNetworkImageSource, isTestNet } from '../../../../../util/networks'; +import { selectNetworkConfigurationsByCaipChainId } from '../../../../../selectors/networkController'; +import { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { PopularList } from '../../../../../util/networks/customNetworks'; /** * Hook to get popular networks, combining networks from Redux state and PopularList. @@ -20,7 +24,6 @@ export const usePopularNetworks = (): ProcessedNetwork[] => { const networkConfigurations = useSelector( selectNetworkConfigurationsByCaipChainId, ); - return useMemo(() => { const processedNetworks: ProcessedNetwork[] = []; const addedCaipChainIds = new Set(); @@ -35,19 +38,19 @@ export const usePopularNetworks = (): ProcessedNetwork[] => { return isTestNet(hexChainId); } - // Check Bitcoin testnets + // Check Bitcoin testnets using full CAIP IDs from BtcScope if (namespace === 'bip122') { return ( - reference === BtcScope.Testnet || - reference === BtcScope.Testnet4 || - reference === BtcScope.Regtest || - reference === BtcScope.Signet + caipChainId === BtcScope.Testnet || + caipChainId === BtcScope.Testnet4 || + caipChainId === BtcScope.Regtest || + caipChainId === BtcScope.Signet ); } - // Check Solana testnets + // Check Solana testnets using full CAIP IDs from SolScope if (namespace === 'solana') { - return reference === SolScope.Devnet; + return caipChainId === SolScope.Devnet; } // For other namespaces, assume mainnet if not explicitly a testnet @@ -56,8 +59,17 @@ export const usePopularNetworks = (): ProcessedNetwork[] => { // First, add all networks from networkConfigurations (excluding testnets) for (const [caipChainId, config] of Object.entries(networkConfigurations)) { - // Skip testnets using isTestnet helper - if (isTestnetCaipChainId(caipChainId as CaipChainId)) { + // Skip testnets using isTestnet helper and custom networks based of rpcEndpoints[defaultRpcEndpointIndex].type + const isEvmCustomChain = + config.caipChainId.startsWith('eip155') && + (config as NetworkConfiguration).rpcEndpoints?.[ + (config as NetworkConfiguration).defaultRpcEndpointIndex + ]?.type === RpcEndpointType.Custom; + + if ( + isTestnetCaipChainId(caipChainId as CaipChainId) || + isEvmCustomChain + ) { continue; } diff --git a/app/components/hooks/usePopularNetworks.test.ts b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts similarity index 62% rename from app/components/hooks/usePopularNetworks.test.ts rename to app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts index 5eee737dee4..f609370f645 100644 --- a/app/components/hooks/usePopularNetworks.test.ts +++ b/app/components/UI/Trending/hooks/usePopularNetworks/usePopularNetworks.test.ts @@ -1,19 +1,20 @@ import { renderHook } from '@testing-library/react-native'; import { useSelector } from 'react-redux'; import { CaipChainId } from '@metamask/utils'; -import { isTestNet } from '../../util/networks'; -import { usePopularNetworks } from './usePopularNetworks'; +import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { isTestNet } from '../../../../../util/networks'; +import { usePopularNetworks } from '.'; jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('../../util/networks', () => ({ +jest.mock('../../../../../util/networks', () => ({ getNetworkImageSource: jest.fn(), isTestNet: jest.fn(), })); -jest.mock('../../util/networks/customNetworks', () => ({ +jest.mock('../../../../../util/networks/customNetworks', () => ({ PopularList: [ { chainId: '0xa86a', @@ -140,6 +141,116 @@ describe('usePopularNetworks', () => { expect(result.current.some((n) => n.name === 'Arbitrum')).toBe(true); expect(result.current.some((n) => n.name === 'BNB Chain')).toBe(true); }); + + it('filters out Bitcoin testnets from networkConfigurations', () => { + const mockNetworkConfigurations = { + // Bitcoin mainnet example + [BtcScope.Mainnet]: { + caipChainId: BtcScope.Mainnet as CaipChainId, + name: 'Bitcoin', + }, + // Bitcoin testnet variants using full CAIP IDs from BtcScope + [BtcScope.Testnet]: { + caipChainId: BtcScope.Testnet as CaipChainId, + name: 'Bitcoin Testnet', + }, + [BtcScope.Testnet4]: { + caipChainId: BtcScope.Testnet4 as CaipChainId, + name: 'Bitcoin Testnet4', + }, + [BtcScope.Regtest]: { + caipChainId: BtcScope.Regtest as CaipChainId, + name: 'Bitcoin Regtest', + }, + [BtcScope.Signet]: { + caipChainId: BtcScope.Signet as CaipChainId, + name: 'Bitcoin Signet', + }, + }; + + mockUseSelector.mockReturnValue(mockNetworkConfigurations); + + const { result } = renderHook(() => usePopularNetworks()); + + expect(result.current.some((n) => n.name === 'Bitcoin')).toBe(true); + expect(result.current.some((n) => n.name === 'Bitcoin Testnet')).toBe( + false, + ); + expect(result.current.some((n) => n.name === 'Bitcoin Testnet4')).toBe( + false, + ); + expect(result.current.some((n) => n.name === 'Bitcoin Regtest')).toBe( + false, + ); + expect(result.current.some((n) => n.name === 'Bitcoin Signet')).toBe( + false, + ); + }); + + it('filters out Solana Devnet from networkConfigurations', () => { + const mockNetworkConfigurations = { + [SolScope.Mainnet]: { + caipChainId: SolScope.Mainnet as CaipChainId, + name: 'Solana Mainnet', + }, + [SolScope.Devnet]: { + caipChainId: SolScope.Devnet as CaipChainId, + name: 'Solana Devnet', + }, + }; + + mockUseSelector.mockReturnValue(mockNetworkConfigurations); + + const { result } = renderHook(() => usePopularNetworks()); + + expect(result.current.some((n) => n.name === 'Solana Mainnet')).toBe( + true, + ); + expect(result.current.some((n) => n.name === 'Solana Devnet')).toBe( + false, + ); + }); + }); + + describe('custom network filtering', () => { + it('filters EVM custom networks from networkConfigurations', () => { + const mockNetworkConfigurations = { + 'eip155:1': { + caipChainId: 'eip155:1' as CaipChainId, + name: 'Ethereum Mainnet', + }, + 'eip155:81457': { + caipChainId: 'eip155:81457' as CaipChainId, + chainId: '0x13e31', + name: 'blast', + rpcEndpoints: [ + { + url: 'https://blast-rpc.publicnode.com', + name: '', + // Match RpcEndpointType.Custom value used in the hook + type: 'custom', + networkClientId: '0c8dd6d9-a167-4656-9057-b5daf33dbbde', + }, + ], + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + lastUpdatedAt: 1763644775633, + }, + }; + + mockUseSelector.mockReturnValue(mockNetworkConfigurations); + + const { result } = renderHook(() => usePopularNetworks()); + + expect( + result.current.some( + (network) => network.caipChainId === 'eip155:81457', + ), + ).toBe(false); + expect( + result.current.some((network) => network.caipChainId === 'eip155:1'), + ).toBe(true); + }); }); describe('sorting', () => { diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts b/app/components/UI/Trending/hooks/useTrendingRequest/index.ts index 18345c6a95f..da61d8ed85a 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/index.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/index.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import { debounce } from 'lodash'; -import { CaipChainId, parseCaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import { getTrendingTokens, SortTrendingBy, @@ -15,108 +15,6 @@ import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworks export const DEBOUNCE_WAIT = 500; -/** - * Performance Optimization: Simple cache with TTL (30 seconds) - * - * The key optimization that makes navigation snappy is using lazy initialization - * in useState to check the cache synchronously during component mount. This allows: - * 1. Immediate render with cached data (no async state updates blocking navigation) - * 2. Avoids unnecessary API calls when navigating back and forth - * 3. Component renders instantly if cache exists, fetch happens in background if needed - * - * Without this pattern, async state updates in useEffect would block navigation, - * causing the "view all" button to require multiple clicks and feel laggy. - */ -const CACHE_DURATION_MS = 30 * 1000; -const cache = new Map< - string, - { data: Awaited>; timestamp: number } ->(); - -/** - * Compare function for CAIP chain IDs to ensure consistent sorting - * First compares by namespace (alphabetically), then by reference - * (numerically if both are numbers, otherwise alphabetically) - */ -const compareCaipChainIds = (a: CaipChainId, b: CaipChainId): number => { - try { - const { namespace: namespaceA, reference: refA } = parseCaipChainId(a); - const { namespace: namespaceB, reference: refB } = parseCaipChainId(b); - - // First compare namespaces - if (namespaceA !== namespaceB) { - return namespaceA.localeCompare(namespaceB); - } - - // Then compare references - try numeric comparison first - const numA = Number(refA); - const numB = Number(refB); - if (!isNaN(numA) && !isNaN(numB)) { - return numA - numB; - } - - // Fallback to alphabetical comparison for non-numeric references - return refA.localeCompare(refB); - } catch { - // If parsing fails, fall back to string comparison - return a.localeCompare(b); - } -}; - -// Generate cache key from options -const getCacheKey = (options: { - chainIds: CaipChainId[]; - sortBy?: SortTrendingBy; - minLiquidity?: number; - minVolume24hUsd?: number; - maxVolume24hUsd?: number; - minMarketCap?: number; - maxMarketCap?: number; -}): string => { - // Sort chain IDs using compare function to ensure consistent cache keys - // regardless of input order - const sortedChainIds = [...options.chainIds].sort(compareCaipChainIds); - return JSON.stringify({ - chainIds: sortedChainIds, - sortBy: options.sortBy, - minLiquidity: options.minLiquidity, - minVolume24hUsd: options.minVolume24hUsd, - maxVolume24hUsd: options.maxVolume24hUsd, - minMarketCap: options.minMarketCap, - maxMarketCap: options.maxMarketCap, - }); -}; - -// Check if cache entry is valid -const isCacheValid = ( - entry: - | { data: Awaited>; timestamp: number } - | undefined, -): boolean => { - if (!entry) return false; - return Date.now() - entry.timestamp < CACHE_DURATION_MS; -}; - -/** - * Simple cleanup: Remove expired entries from cache - * Only called when storing new entries (non-blocking, doesn't affect navigation) - */ -const cleanupExpiredEntries = (): void => { - const now = Date.now(); - for (const [key, entry] of cache.entries()) { - if (now - entry.timestamp >= CACHE_DURATION_MS) { - cache.delete(key); - } - } -}; - -/** - * Clear all cache entries - useful for testing - */ -export const clearCache = (): void => { - cache.clear(); -}; - /** * Hook for handling trending tokens request * @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch @@ -188,37 +86,11 @@ export const useTrendingRequest = (options: { ], ); - /** - * Performance Optimization: Lazy initialization in useState - * - * This is the critical fix that makes navigation snappy. By checking the cache - * synchronously in the useState initializer function, we can: - * - Render immediately with cached data (no loading state delay) - * - Avoid blocking navigation with async state updates - * - Ensure the component is ready to render as soon as it mounts - * - * If we used useEffect to check cache, it would run after render, causing: - * - Initial render with loading state - * - Async state update that could block navigation - * - Multiple clicks needed on "view all" button - */ const [results, setResults] = useState - > | null>(() => { - if (!stableChainIds.length) return null; - const cacheKey = getCacheKey(memoizedOptions); - const cached = cache.get(cacheKey); - if (cached && isCacheValid(cached)) { - return cached.data; - } - return null; - }); + > | null>(null); - const [isLoading, setIsLoading] = useState(() => { - if (!stableChainIds.length) return false; - const cacheKey = getCacheKey(memoizedOptions); - return !isCacheValid(cache.get(cacheKey)); - }); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -230,16 +102,6 @@ export const useTrendingRequest = (options: { return; } - // Check cache first - const cacheKey = getCacheKey(memoizedOptions); - const cached = cache.get(cacheKey); - if (cached && isCacheValid(cached)) { - setResults(cached.data); - setIsLoading(false); - setError(null); - return; - } - // Increment request ID to mark this as the current request const currentRequestId = ++requestIdRef.current; setIsLoading(true); @@ -258,13 +120,6 @@ export const useTrendingRequest = (options: { // Only update state if this is still the current request if (currentRequestId === requestIdRef.current) { setResults(resultsToStore); - // Store in cache and cleanup expired entries (non-blocking) - cache.set(cacheKey, { - data: resultsToStore, - timestamp: Date.now(), - }); - // Cleanup expired entries when storing new data (doesn't block navigation) - cleanupExpiredEntries(); } } catch (err) { // Only update state if this is still the current request @@ -298,6 +153,9 @@ export const useTrendingRequest = (options: { return; } + // Immediately show loading state so UI can render skeleton right away + setIsLoading(true); + // Fetch new data debouncedFetchTrendingTokens(); diff --git a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts index 39d242c53a7..1a0974a43aa 100644 --- a/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts +++ b/app/components/UI/Trending/hooks/useTrendingRequest/useTrendingRequest.test.ts @@ -1,4 +1,4 @@ -import { DEBOUNCE_WAIT, useTrendingRequest, clearCache } from '.'; +import { DEBOUNCE_WAIT, useTrendingRequest } from '.'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { act } from '@testing-library/react-native'; // eslint-disable-next-line import/no-namespace @@ -53,8 +53,6 @@ describe('useTrendingRequest', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - // Clear cache between tests to ensure test isolation - clearCache(); // Set up default mocks for network hooks mockUseNetworksByNamespace.mockReturnValue({ networks: mockDefaultNetworks, @@ -79,7 +77,6 @@ describe('useTrendingRequest', () => { afterEach(() => { jest.useRealTimers(); - clearCache(); }); it('returns an object with results, isLoading, error, and fetch function', () => { @@ -392,8 +389,6 @@ describe('useTrendingRequest', () => { await Promise.resolve(); }); - // Clear cache so subsequent fetch calls will actually trigger API calls - clearCache(); spyGetTrendingTokens.mockClear(); await act(async () => { diff --git a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts similarity index 89% rename from app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts rename to app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts index a91800c34e4..75b53fae73f 100644 --- a/app/components/UI/Perps/components/PerpsMarketListHeader/PerpsMarketListHeader.styles.ts +++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.styles.ts @@ -1,8 +1,8 @@ import { StyleSheet } from 'react-native'; -import type { Theme } from '../../../../../util/theme/models'; +import type { Theme } from '../../../../util/theme/models'; /** - * Styles for PerpsMarketListHeader component + * Styles for ListHeaderWithSearch component */ const styleSheet = (params: { theme: Theme }) => { const { theme } = params; diff --git a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx new file mode 100644 index 00000000000..b242d24e72e --- /dev/null +++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.tsx @@ -0,0 +1,176 @@ +import React, { useCallback } from 'react'; +import { + View, + TouchableOpacity, + Pressable, + Keyboard, + TextInput, + Platform, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useStyles } from '../../../../component-library/hooks'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../../component-library/components/Icons/Icon'; +import Text, { + TextVariant, + TextColor, +} from '../../../../component-library/components/Texts/Text'; +import { useTheme } from '../../../../util/theme'; +import type { ListHeaderWithSearchProps } from './ListHeaderWithSearch.types'; +import styleSheet from './ListHeaderWithSearch.styles'; + +/** + * ListHeaderWithSearch Component + * + * Reusable header component for list views with back button, + * title, and search toggle functionality + * + * Features: + * - Back button with default or custom navigation handler + * - Centered title with custom text support + * - Search toggle button that changes icon based on visibility + * - Keyboard dismiss on header press + * - Configurable search placeholder and cancel text + * + * @example + * ```tsx + * + * ``` + * + * @example Custom back handler + * ```tsx + * + * ``` + */ +const ListHeaderWithSearch: React.FC = ({ + title, + defaultTitle, + isSearchVisible = false, + searchQuery = '', + searchPlaceholder, + cancelText, + onSearchQueryChange, + onSearchClear: _onSearchClear, // Not used - clear icon removed + onBack, + onSearchToggle, + testID, +}) => { + const { styles } = useStyles(styleSheet, {}); + const tw = useTailwind(); + const { colors } = useTheme(); + const navigation = useNavigation(); + + // Default back handler + const defaultHandleBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + } + }, [navigation]); + + // Use custom handler if provided, otherwise use default + const handleBack = onBack || defaultHandleBack; + + return ( + Keyboard.dismiss()} + testID={testID} + > + {isSearchVisible ? ( + + {/* Search Bar - Replaces back button and title */} + + + + + {/* Cancel Button */} + + + {cancelText} + + + + ) : ( + + {/* Back Button */} + + + + + {/* Title */} + + + {title || defaultTitle} + + + + {/* Search Toggle Button */} + + + + + + + )} + + ); +}; + +export default ListHeaderWithSearch; diff --git a/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts new file mode 100644 index 00000000000..376b72584c6 --- /dev/null +++ b/app/components/UI/shared/ListHeaderWithSearch/ListHeaderWithSearch.types.ts @@ -0,0 +1,61 @@ +/** + * Props for ListHeaderWithSearch component + */ +export interface ListHeaderWithSearchProps { + /** + * Header title text + */ + title?: string; + + /** + * Default title to use if title prop is not provided + */ + defaultTitle: string; + + /** + * Whether search bar is currently visible + * @default false + */ + isSearchVisible?: boolean; + + /** + * Search query value (required when isSearchVisible is true) + */ + searchQuery?: string; + + /** + * Search placeholder text + */ + searchPlaceholder: string; + + /** + * Cancel button text + */ + cancelText: string; + + /** + * Callback when search query changes + */ + onSearchQueryChange?: (query: string) => void; + + /** + * Callback when search clear button is pressed + */ + onSearchClear?: () => void; + + /** + * Callback when back button is pressed + * If not provided, uses default navigation.goBack() + */ + onBack?: () => void; + + /** + * Callback when search toggle button is pressed + */ + onSearchToggle?: () => void; + + /** + * Test ID for the header container + */ + testID?: string; +} diff --git a/app/components/UI/shared/ListHeaderWithSearch/index.ts b/app/components/UI/shared/ListHeaderWithSearch/index.ts new file mode 100644 index 00000000000..59a9bc70bbf --- /dev/null +++ b/app/components/UI/shared/ListHeaderWithSearch/index.ts @@ -0,0 +1,2 @@ +export { default } from './ListHeaderWithSearch'; +export type { ListHeaderWithSearchProps } from './ListHeaderWithSearch.types'; diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx index ea9a3b130f0..d3d1ab101da 100644 --- a/app/components/Views/NftDetails/NftDetails.tsx +++ b/app/components/Views/NftDetails/NftDetails.tsx @@ -190,7 +190,10 @@ const NftDetails = () => { dispatch( newAssetTransaction({ contractName: collectible.name, ...collectible }), ); - navigateToSendPage(InitSendLocation.NftDetails, collectible); + navigateToSendPage({ + location: InitSendLocation.NftDetails, + asset: collectible, + }); }, [collectible, chainId, dispatch, navigateToSendPage]); const isTradable = useCallback( diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index d494b136775..008b80dec83 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -15,11 +15,32 @@ jest.mock('@react-navigation/native', () => ({ createNavigatorFactory: () => ({}), })); -const mockUseTrendingRequest = jest.fn(); +const mockFetchTrendingTokens = jest.fn(); +const mockUseTrendingRequest = jest.fn().mockReturnValue({ + results: [], + isLoading: false, + error: null, + fetch: mockFetchTrendingTokens, +}); jest.mock('../../../UI/Trending/hooks/useTrendingRequest', () => ({ useTrendingRequest: (options: unknown) => mockUseTrendingRequest(options), })); +const mockUseSectionData = jest.fn(); + +// Mock sections.config to avoid complex Perps dependencies +// Make useSectionData return the same data as useTrendingRequest +jest.mock('../../TrendingView/config/sections.config', () => ({ + SECTIONS_CONFIG: { + tokens: { + useSectionData: (params?: { searchQuery?: string }) => + mockUseSectionData(params), + getSearchableText: (item: { name?: string; symbol?: string }) => + `${item.name || ''} ${item.symbol || ''}`.toLowerCase(), + }, + }, +})); + jest.mock( '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList', () => { @@ -27,11 +48,12 @@ jest.mock( return ({ trendingTokens, onTokenPress, + ...rest }: { trendingTokens: TrendingAsset[]; onTokenPress: (token: TrendingAsset) => void; }) => ( - + {trendingTokens.map((token, index) => ( { }; }); -jest.mock('../../../../component-library/components/HeaderBase', () => { - const { View } = jest.requireActual('react-native'); - const MockHeaderBase = ({ - children, - startAccessory, - endAccessory, - }: { - children: React.ReactNode; - startAccessory?: React.ReactNode; - endAccessory?: React.ReactNode; - }) => ( - - {startAccessory} - {children} - {endAccessory} - - ); - return { - __esModule: true, - default: MockHeaderBase, - HeaderBaseVariant: { - Display: 'display', - Compact: 'compact', - }, - }; -}); - -jest.mock('../../../../component-library/components/Buttons/ButtonIcon', () => { - const { TouchableOpacity } = jest.requireActual('react-native'); - const MockButtonIcon = ({ - onPress, - testID, - }: { - onPress?: () => void; - testID?: string; - }) => ( - - ButtonIcon - - ); - return { - __esModule: true, - default: MockButtonIcon, - ButtonIconSizes: { - Sm: '24', - Md: '28', - Lg: '32', - }, - }; -}); - -jest.mock('../../../../component-library/components/Texts/Text', () => { - const { Text } = jest.requireActual('react-native'); - return { - __esModule: true, - default: Text, - TextVariant: { - HeadingMD: 'HeadingMD', - }, - TextColor: { - Default: 'Default', - }, - }; -}); - -jest.mock('../../../../component-library/components/Icons/Icon', () => { - const { View } = jest.requireActual('react-native'); - return { - __esModule: true, - default: function MockIcon({ name }: { name: string }) { - return {name}; - }, - IconName: { - ArrowLeft: 'ArrowLeft', - Search: 'Search', - ArrowDown: 'ArrowDown', - }, - IconColor: { - Alternative: 'Alternative', - }, - IconSize: { - Xs: 'Xs', - }, - }; -}); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: (key: string) => { - const translations: Record = { - 'trending.trending_tokens': 'Trending Tokens', - 'trending.price_change': 'Price change', - 'trending.all_networks': 'All networks', - 'trending.24h': '24h', - }; - return translations[key] || key; - }, -})); - const createMockToken = ( overrides: Partial = {}, ): TrendingAsset => ({ @@ -292,6 +216,11 @@ describe('TrendingTokensFullView', () => { error: null, fetch: jest.fn(), }); + mockUseSectionData.mockReturnValue({ + data: [], + isLoading: false, + refetch: jest.fn(), + }); }); it('renders header with title and buttons', () => { @@ -301,9 +230,8 @@ describe('TrendingTokensFullView', () => { false, // Exclude NavigationContainer since we're mocking navigation ); - expect(getByText('Trending Tokens')).toBeTruthy(); - expect(getByTestId('back-button')).toBeTruthy(); - expect(getByTestId('search-button')).toBeTruthy(); + expect(getByText('Trending Tokens')).toBeOnTheScreen(); + expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen(); }); it('renders control buttons', () => { @@ -313,12 +241,12 @@ describe('TrendingTokensFullView', () => { false, ); - expect(getByTestId('price-change-button')).toBeTruthy(); - expect(getByTestId('all-networks-button')).toBeTruthy(); - expect(getByTestId('24h-button')).toBeTruthy(); - expect(getByText('Price change')).toBeTruthy(); - expect(getByText('All networks')).toBeTruthy(); - expect(getByText('24h')).toBeTruthy(); + expect(getByTestId('price-change-button')).toBeOnTheScreen(); + expect(getByTestId('all-networks-button')).toBeOnTheScreen(); + expect(getByTestId('24h-button')).toBeOnTheScreen(); + expect(getByText('Price change')).toBeOnTheScreen(); + expect(getByText('All networks')).toBeOnTheScreen(); + expect(getByText('24h')).toBeOnTheScreen(); }); it('navigates back when back button is pressed', () => { @@ -328,7 +256,7 @@ describe('TrendingTokensFullView', () => { false, ); - const backButton = getByTestId('back-button'); + const backButton = getByTestId('trending-tokens-header-back-button'); fireEvent.press(backButton); expect(mockGoBack).toHaveBeenCalled(); @@ -344,7 +272,7 @@ describe('TrendingTokensFullView', () => { const button24h = getByTestId('24h-button'); fireEvent.press(button24h); - expect(getByTestId('trending-token-time-bottom-sheet')).toBeTruthy(); + expect(getByTestId('trending-token-time-bottom-sheet')).toBeOnTheScreen(); }); it('opens network bottom sheet when all networks button is pressed', () => { @@ -357,7 +285,9 @@ describe('TrendingTokensFullView', () => { const allNetworksButton = getByTestId('all-networks-button'); fireEvent.press(allNetworksButton); - expect(getByTestId('trending-token-network-bottom-sheet')).toBeTruthy(); + expect( + getByTestId('trending-token-network-bottom-sheet'), + ).toBeOnTheScreen(); }); it('opens price change bottom sheet when price change button is pressed', () => { @@ -389,7 +319,7 @@ describe('TrendingTokensFullView', () => { false, ); - expect(getByTestId('trending-tokens-skeleton')).toBeTruthy(); + expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen(); }); it('displays skeleton loader when results are empty', () => { @@ -406,7 +336,7 @@ describe('TrendingTokensFullView', () => { false, ); - expect(getByTestId('trending-tokens-skeleton')).toBeTruthy(); + expect(getByTestId('trending-tokens-skeleton')).toBeOnTheScreen(); }); it('displays trending tokens list when data is loaded', () => { @@ -422,23 +352,30 @@ describe('TrendingTokensFullView', () => { fetch: jest.fn(), }); + mockUseSectionData.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + const { getByTestId, getByText } = renderWithProvider( , { state: mockState }, false, ); - expect(getByTestId('trending-tokens-list')).toBeTruthy(); - expect(getByText('Token 1')).toBeTruthy(); - expect(getByText('Token 2')).toBeTruthy(); + expect(getByTestId('trending-tokens-list')).toBeOnTheScreen(); + expect(getByText('Token 1')).toBeOnTheScreen(); + expect(getByText('Token 2')).toBeOnTheScreen(); }); - it('calls useTrendingRequest with correct initial parameters', () => { + it('calls useSectionData with correct initial parameters', () => { renderWithProvider(, { state: mockState }, false); - expect(mockUseTrendingRequest).toHaveBeenCalledWith({ + expect(mockUseSectionData).toHaveBeenCalledWith({ sortBy: undefined, - chainIds: undefined, + chainIds: null, + searchQuery: undefined, }); }); @@ -458,9 +395,10 @@ describe('TrendingTokensFullView', () => { }); await waitFor(() => { - expect(mockUseTrendingRequest).toHaveBeenLastCalledWith({ + expect(mockUseSectionData).toHaveBeenLastCalledWith({ sortBy: 'h6_trending', - chainIds: undefined, + chainIds: null, + searchQuery: undefined, }); }); }); @@ -481,10 +419,90 @@ describe('TrendingTokensFullView', () => { }); await waitFor(() => { - expect(mockUseTrendingRequest).toHaveBeenLastCalledWith({ + expect(mockUseSectionData).toHaveBeenLastCalledWith({ sortBy: undefined, chainIds: ['eip155:1'], + searchQuery: undefined, }); }); }); + + it('updates price change filter when option is selected', async () => { + const mockTokens = [ + createMockToken({ name: 'Token 1', assetId: 'eip155:1/erc20:0x123' }), + createMockToken({ name: 'Token 2', assetId: 'eip155:1/erc20:0x456' }), + ]; + + mockUseTrendingRequest.mockReturnValue({ + results: mockTokens, + isLoading: false, + error: null, + fetch: jest.fn(), + }); + + mockUseSectionData.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: jest.fn(), + }); + + const { getByTestId, getByText } = renderWithProvider( + , + { state: mockState }, + false, + ); + + // Open price change bottom sheet + const priceChangeButton = getByTestId('price-change-button'); + fireEvent.press(priceChangeButton); + + // Select Volume option (which maps to PriceChangeOption.Volume and ascending sort) + const volumeOption = getByTestId('price-change-select-volume'); + await act(async () => { + fireEvent(volumeOption, 'touchEnd'); + }); + + // Price change button label should update to "Volume" + expect(getByText('Volume')).toBeOnTheScreen(); + }); + + it('triggers section refetch on pull-to-refresh', async () => { + const mockTokens = [ + createMockToken({ + assetId: 'eip155:1/erc20:0xabc', + name: 'Token 1', + symbol: 'TKN1', + }), + ]; + + mockUseTrendingRequest.mockReturnValueOnce({ + results: mockTokens, + isLoading: false, + error: null, + fetch: mockFetchTrendingTokens, + }); + + mockUseSectionData.mockReturnValue({ + data: mockTokens, + isLoading: false, + refetch: mockFetchTrendingTokens, + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + false, + ); + + const list = getByTestId('trending-tokens-list'); + + // Simulate pull-to-refresh via RefreshControl's onRefresh + const refreshControl = list.props.refreshControl; + + await act(async () => { + await refreshControl.props.onRefresh(); + }); + + expect(mockFetchTrendingTokens).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx index 0dd37e6eeae..81a52016284 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.tsx @@ -5,33 +5,32 @@ import { SafeAreaView, useSafeAreaInsets, } from 'react-native-safe-area-context'; -import { StyleSheet, View, TouchableOpacity } from 'react-native'; +import { + StyleSheet, + View, + TouchableOpacity, + RefreshControl, +} from 'react-native'; import { useSelector } from 'react-redux'; import { useAppThemeFromContext } from '../../../../util/theme'; import { Theme } from '../../../../util/theme/models'; import { selectNetworkConfigurationsByCaipChainId } from '../../../../selectors/networkController'; -import HeaderBase, { - HeaderBaseVariant, -} from '../../../../component-library/components/HeaderBase'; -import ButtonIcon, { - ButtonIconSizes, -} from '../../../../component-library/components/Buttons/ButtonIcon'; import Icon, { IconName, IconColor, IconSize, } from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; +import { TrendingListHeader } from '../../../UI/Trending/components/TrendingListHeader'; import TrendingTokensList from '../../../UI/Trending/components/TrendingTokensList/TrendingTokensList'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; -import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest'; -import { SortTrendingBy } from '@metamask/assets-controllers'; +import { + SortTrendingBy, + type TrendingAsset, +} from '@metamask/assets-controllers'; import { CaipChainId, Hex, parseCaipChainId } from '@metamask/utils'; import { PopularList } from '../../../../util/networks/customNetworks'; -import Text, { - TextColor, - TextVariant, -} from '../../../../component-library/components/Texts/Text'; +import Text from '../../../../component-library/components/Texts/Text'; import { TrendingTokenTimeBottomSheet, TrendingTokenNetworkBottomSheet, @@ -41,6 +40,7 @@ import { TimeOption, } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; +import { SECTIONS_CONFIG } from '../../TrendingView/config/sections.config'; interface TrendingTokensNavigationParamList { [key: string]: undefined | object; @@ -56,14 +56,6 @@ const createStyles = (theme: Theme) => headerContainer: { backgroundColor: theme.colors.background.default, }, - header: { - paddingTop: 16, - paddingBottom: 0, - paddingHorizontal: 16, - alignItems: 'center', - gap: 8, - alignSelf: 'stretch', - }, cardContainer: { margin: 16, borderRadius: 16, @@ -147,11 +139,25 @@ const TrendingTokensFullView = () => { const [showNetworkBottomSheet, setShowNetworkBottomSheet] = useState(false); const [showPriceChangeBottomSheet, setShowPriceChangeBottomSheet] = useState(false); + const [isSearchVisible, setIsSearchVisible] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [refreshing, setRefreshing] = useState(false); const handleBackPress = useCallback(() => { navigation.goBack(); }, [navigation]); + const handleSearchToggle = useCallback(() => { + setIsSearchVisible((prev) => !prev); + if (isSearchVisible) { + setSearchQuery(''); + } + }, [isSearchVisible]); + + const handleSearchQueryChange = useCallback((query: string) => { + setSearchQuery(query); + }, []); + const networkConfigurations = useSelector( selectNetworkConfigurationsByCaipChainId, ); @@ -188,26 +194,57 @@ const TrendingTokensFullView = () => { return strings('trending.all_networks'); }, [selectedNetwork, networkConfigurations]); - const { results: trendingTokensResults, isLoading } = useTrendingRequest({ + // Use tokens section data as the single source of truth: + // - When no search query: returns trending results from useTrendingRequest + // - When search query exists: returns merged trending + search results + const { + data: tokensSectionData, + isLoading, + refetch: refetchTokensSection, + } = SECTIONS_CONFIG.tokens.useSectionData({ + searchQuery: searchQuery || undefined, sortBy, - chainIds: selectedNetwork ?? undefined, + chainIds: selectedNetwork, }); + const searchResults = useMemo(() => { + // When search is not active, use the full section data + if (!isSearchVisible) { + return tokensSectionData as TrendingAsset[]; + } + + const searchTerm = searchQuery.toLowerCase().trim(); + + // If search box is empty, still use full section data + if (!searchTerm) { + return tokensSectionData as TrendingAsset[]; + } + + const tokensSectionConfig = SECTIONS_CONFIG.tokens; + + // Filter section data based on searchable text (symbol + name) + return (tokensSectionData as unknown[]).filter((item) => + tokensSectionConfig.getSearchableText(item).includes(searchTerm), + ) as TrendingAsset[]; + }, [isSearchVisible, searchQuery, tokensSectionData]); + // Sort and display tokens based on selected option and direction const trendingTokens = useMemo(() => { // Early return if no results - if (trendingTokensResults.length === 0) { + if (searchResults.length === 0) { return []; } - // If no sort option selected, return results as-is (already sorted by API) + const filteredResults = searchResults; + + // If no sort option selected, return filtered results as-is (already sorted by API) if (!selectedPriceChangeOption) { - return trendingTokensResults.slice(0, MAX_TOKENS); + return filteredResults.slice(0, MAX_TOKENS); } // Sort using the shared utility function const sorted = sortTrendingTokens( - trendingTokensResults, + filteredResults, selectedPriceChangeOption, priceChangeSortDirection, selectedTimeOption, @@ -215,7 +252,7 @@ const TrendingTokensFullView = () => { return sorted.slice(0, MAX_TOKENS); }, [ - trendingTokensResults, + searchResults, selectedPriceChangeOption, priceChangeSortDirection, selectedTimeOption, @@ -253,6 +290,18 @@ const TrendingTokensFullView = () => { setShowTimeBottomSheet(true); }, []); + // Handle pull-to-refresh + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + refetchTokensSection?.(); + } catch (error) { + console.warn('Failed to refresh trending tokens:', error); + } finally { + setRefreshing(false); + } + }, [refetchTokensSection]); + // Get the button text based on selected price change option const priceChangeButtonText = useMemo(() => { switch (selectedPriceChangeOption) { @@ -276,79 +325,28 @@ const TrendingTokensFullView = () => { }, ]} > - - } - endAccessory={ - { - // TODO: Implement search functionality - }} - iconName={IconName.Search} - testID="search-button" - /> - } - style={styles.header} - > - - {strings('trending.trending_tokens')} - - + - - - - - - {priceChangeButtonText} - - - - - - - - - {selectedNetworkName} - - - - + {!isSearchVisible ? ( + + - {selectedTimeOption} + {priceChangeButtonText} { /> + + + + + {selectedNetworkName} + + + + + + + + {selectedTimeOption} + + + + + - - {isLoading || trendingTokensResults.length === 0 ? ( + ) : null} + + {isLoading || (searchResults as TrendingAsset[]).length === 0 ? ( @@ -369,6 +404,14 @@ const TrendingTokensFullView = () => { + } /> )} diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx index 54e27b0ab7f..0506d56ef74 100644 --- a/app/components/Views/TrendingView/config/sections.config.tsx +++ b/app/components/Views/TrendingView/config/sections.config.tsx @@ -1,6 +1,10 @@ import React from 'react'; import type { NavigationProp, ParamListBase } from '@react-navigation/native'; -import type { TrendingAsset } from '@metamask/assets-controllers'; +import type { + TrendingAsset, + SortTrendingBy, +} from '@metamask/assets-controllers'; +import Routes from '../../../../constants/navigation/Routes'; import { strings } from '../../../../../locales/i18n'; import TrendingTokenRowItem from '../../../UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem'; import TrendingTokensSkeleton from '../../../UI/Trending/components/TrendingTokenSkeleton/TrendingTokensSkeleton'; @@ -15,10 +19,7 @@ import SectionCard from '../components/SectionCard/SectionCard'; import SectionCarrousel from '../components/SectionCarrousel/SectionCarrousel'; import { useTrendingRequest } from '../../../UI/Trending/hooks/useTrendingRequest'; import { sortTrendingTokens } from '../../../UI/Trending/utils/sortTrendingTokens'; -import { - PriceChangeOption, - SortDirection, -} from '../../../UI/Trending/components/TrendingTokensBottomSheet'; +import { PriceChangeOption } from '../../../UI/Trending/components/TrendingTokensBottomSheet'; import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData'; import { usePerpsMarkets } from '../../../UI/Perps/hooks'; import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider'; @@ -29,13 +30,20 @@ import type { SiteData } from '../SectionSites/SiteRowItem/SiteRowItem'; import SiteRowItemWrapper from '../SectionSites/SiteRowItemWrapper'; import SiteSkeleton from '../SectionSites/SiteSkeleton/SiteSkeleton'; import { useSitesData } from '../SectionSites/hooks/useSitesData'; -import Routes from '../../../../constants/navigation/Routes'; +import { CaipChainId } from '@metamask/utils'; export type SectionId = 'predictions' | 'tokens' | 'perps' | 'sites'; interface SectionData { data: unknown[]; isLoading: boolean; + refetch?: () => void; +} + +interface SectionParams { + searchQuery?: string; + sortBy?: SortTrendingBy; + chainIds?: CaipChainId[] | null; } interface SectionConfig { @@ -51,9 +59,10 @@ interface SectionConfig { getSearchableText: (item: unknown) => string; keyExtractor: (item: unknown) => string; Section: React.ComponentType; - useSectionData: (searchQuery?: string) => { + useSectionData: (params?: SectionParams) => { data: unknown[]; isLoading: boolean; + refetch?: () => void; }; } @@ -89,7 +98,8 @@ export const SECTIONS_CONFIG: Record = { `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(), keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`, Section: () => , - useSectionData: (searchQuery?: string) => { + useSectionData: (params?: SectionParams) => { + const { searchQuery, sortBy, chainIds } = params ?? {}; // Trending will return tokens that have just been created which wont be picked up by search API // so if you see a token on trending and search on omnisearch which uses the search endpoint... // There is a chance you will get 0 results @@ -100,18 +110,26 @@ export const SECTIONS_CONFIG: Record = { chainIds: [], }); - const { results: trendingResults, isLoading: isTrendingLoading } = - useTrendingRequest({}); + const { + results: trendingResults, + isLoading: isTrendingLoading, + fetch: fetchTrendingTokens, + } = useTrendingRequest({ + sortBy, + chainIds: chainIds ?? undefined, + }); if (!searchQuery) { const sortedResults = sortTrendingTokens( trendingResults, PriceChangeOption.PriceChange, - SortDirection.Descending, ); return { data: sortedResults, isLoading: isTrendingLoading, + refetch: () => { + fetchTrendingTokens(); + }, }; } @@ -129,6 +147,9 @@ export const SECTIONS_CONFIG: Record = { return { data: Array.from(resultMap.values()), isLoading: isSearchLoading, + refetch: () => { + fetchTrendingTokens(); + }, }; }, }, @@ -195,7 +216,8 @@ export const SECTIONS_CONFIG: Record = { (item as PredictMarketType).title.toLowerCase(), keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`, Section: () => , - useSectionData: (searchQuery?: string) => { + useSectionData: (params?: SectionParams) => { + const { searchQuery } = params ?? {}; const { marketData, isFetching } = usePredictMarketData({ category: 'trending', pageSize: searchQuery ? 20 : 6, @@ -255,11 +277,11 @@ export const useSectionsData = ( searchQuery?: string, ): Record => { const { data: trendingTokens, isLoading: isTokensLoading } = - SECTIONS_CONFIG.tokens.useSectionData(searchQuery); + SECTIONS_CONFIG.tokens.useSectionData({ searchQuery }); const { data: perpsMarkets, isLoading: isPerpsLoading } = SECTIONS_CONFIG.perps.useSectionData(); const { data: predictionMarkets, isLoading: isPredictionsLoading } = - SECTIONS_CONFIG.predictions.useSectionData(searchQuery); + SECTIONS_CONFIG.predictions.useSectionData({ searchQuery }); const { data: sites, isLoading: isSitesLoading } = SECTIONS_CONFIG.sites.useSectionData(); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index cc8596b7070..d06969bb48e 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -695,14 +695,14 @@ const Wallet = ({ } // Navigate to send flow after successful transaction initialization - navigateToSendPage(InitSendLocation.HomePage); + navigateToSendPage({ location: InitSendLocation.HomePage }); } catch (error) { // Handle any errors that occur during the send flow initiation console.error('Error initiating send flow:', error); // Still attempt to navigate to maintain user flow, but without transaction initialization // The SendFlow view should handle the lack of initialized transaction gracefully - navigateToSendPage(InitSendLocation.HomePage); + navigateToSendPage({ location: InitSendLocation.HomePage }); } }, [ trackEvent, diff --git a/app/components/Views/confirmations/__mocks__/send.mock.ts b/app/components/Views/confirmations/__mocks__/send.mock.ts index c1858a7e81b..117086004ab 100644 --- a/app/components/Views/confirmations/__mocks__/send.mock.ts +++ b/app/components/Views/confirmations/__mocks__/send.mock.ts @@ -34,6 +34,8 @@ export const EVM_NATIVE_ASSET = { decimals: 18, isNative: true, isETH: true, + image: + 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg', logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg', name: 'Ethereum', symbol: 'ETH', diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 30878c0e0db..db913563213 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -59,13 +59,12 @@ const ConfirmWrapped = ({ styles: ReturnType; route?: UnstakeConfirmationViewProps['route']; }) => { - const alerts = useConfirmationAlerts(); const isScrollDisabled = useDisableScroll(); return ( - + @@ -88,7 +87,7 @@ const ConfirmWrapped = ({ <Splash /> </LedgerContextProvider> </QRHardwareContextProvider> - </AlertsContextProvider> + </ConfirmationAlerts> </ConfirmationAssetPollingProvider> </ConfirmationContextProvider> ); @@ -167,6 +166,14 @@ export const Confirm = ({ route }: ConfirmProps) => { ); }; +function ConfirmationAlerts({ children }: { children: ReactNode }) { + const alerts = useConfirmationAlerts(); + + return ( + <AlertsContextProvider alerts={alerts}>{children}</AlertsContextProvider> + ); +} + function Loader() { const { styles } = useStyles(styleSheet, { isFullScreenConfirmation: true }); const params = useParams<ConfirmationParams>(); diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 572648507f0..50e8d5c37d0 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -48,6 +48,7 @@ import Button, { } from '../../../../../../component-library/components/Buttons/Button'; import { useAlerts } from '../../../context/alert-system-context'; import { useTransactionConfirm } from '../../../hooks/transactions/useTransactionConfirm'; +import EngineService from '../../../../../../core/EngineService'; export interface CustomAmountInfoProps { children?: ReactNode; @@ -87,8 +88,9 @@ export const CustomAmountInfo: React.FC<CustomAmountInfoProps> = memo( pendingTokenAmount: amountHumanDebounced, }); - const handleDone = useCallback(async () => { - await updateTokenAmount(); + const handleDone = useCallback(() => { + updateTokenAmount(); + EngineService.flushState(); setIsKeyboardVisible(false); }, [updateTokenAmount]); diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx index 07c3aa67ba8..b8a38bf5863 100644 --- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.test.tsx @@ -16,6 +16,8 @@ import { usePercentageAmount } from '../../../../hooks/send/usePercentageAmount' import { useSendContext } from '../../../../context/send-context'; import { useRouteParams } from '../../../../hooks/send/useRouteParams'; import { useSendType } from '../../../../hooks/send/useSendType'; +import { useParams } from '../../../../../../../util/navigation/navUtils'; +import { useSendActions } from '../../../../hooks/send/useSendActions'; // eslint-disable-next-line import/no-namespace import * as AmountValidation from '../../../../hooks/send/useAmountValidation'; import { getBackgroundColor } from './amount-keyboard.styles'; @@ -53,6 +55,14 @@ jest.mock('../../../../hooks/send/useSendType', () => ({ useSendType: jest.fn(), })); +jest.mock('../../../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + +jest.mock('../../../../hooks/send/useSendActions', () => ({ + useSendActions: jest.fn(), +})); + const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -87,6 +97,9 @@ const mockUsePercentageAmount = usePercentageAmount as jest.MockedFunction< typeof usePercentageAmount >; +const mockUseParams = jest.mocked(useParams); +const mockUseSendActions = jest.mocked(useSendActions); + const renderComponent = ( mockState?: ProviderValues['state'], amount = '100', @@ -113,7 +126,10 @@ const renderComponent = ( describe('Amount', () => { const mockUseSendType = jest.mocked(useSendType); + const mockHandleSubmitPress = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); mockUseSendType.mockReturnValue({ isNonEvmSendType: false, } as unknown as ReturnType<typeof useSendType>); @@ -121,6 +137,10 @@ describe('Amount', () => { getPercentageAmount: () => 10, isMaxAmountSupported: true, } as unknown as ReturnType<typeof usePercentageAmount>); + mockUseParams.mockReturnValue({}); + mockUseSendActions.mockReturnValue({ + handleSubmitPress: mockHandleSubmitPress, + } as unknown as ReturnType<typeof useSendActions>); }); it('renders correctly', () => { @@ -174,6 +194,33 @@ describe('Amount', () => { fireEvent.press(getByText('Continue')); expect(mockValidateNonEvmAmountAsync).toHaveBeenCalled(); }); + + it('calls updateTo and handleSubmitPress when predefinedRecipient is provided', () => { + const mockUpdateTo = jest.fn(); + const predefinedRecipientAddress = + '0x1234567890123456789012345678901234567890'; + + mockUseParams.mockReturnValue({ + predefinedRecipient: { + address: predefinedRecipientAddress, + chainType: 'evm', + }, + }); + mockUseSendContext.mockReturnValue({ + asset: MOCK_EVM_ASSET, + updateAsset: jest.fn(), + updateTo: mockUpdateTo, + } as unknown as ReturnType<typeof useSendContext>); + + const { getByText } = renderComponent(); + fireEvent.press(getByText('Continue')); + + expect(mockUpdateTo).toHaveBeenCalledWith(predefinedRecipientAddress); + expect(mockHandleSubmitPress).toHaveBeenCalledWith( + predefinedRecipientAddress, + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); }); describe('getBackgroundColor', () => { diff --git a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx index 9ac70d810e2..6993109ba82 100644 --- a/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount-keyboard/amount-keyboard.tsx @@ -7,6 +7,7 @@ import Button, { ButtonVariants, ButtonWidthTypes, } from '../../../../../../../component-library/components/Buttons/Button'; +import { useParams } from '../../../../../../../util/navigation/navUtils.ts'; import { useStyles } from '../../../../../../hooks/useStyles'; import { AssetType, TokenStandard } from '../../../../types/token'; import { getFractionLength } from '../../../../utils/send.ts'; @@ -16,7 +17,9 @@ import { useCurrencyConversions } from '../../../../hooks/send/useCurrencyConver import { usePercentageAmount } from '../../../../hooks/send/usePercentageAmount'; import { useSendType } from '../../../../hooks/send/useSendType'; import { useSendContext } from '../../../../context/send-context'; +import { type PredefinedRecipient } from '../../../../utils/send'; import { useSendScreenNavigation } from '../../../../hooks/send/useSendScreenNavigation'; +import { useSendActions } from '../../../../hooks/send/useSendActions'; import { EditAmountKeyboard } from '../../../edit-amount-keyboard'; import { styleSheet } from './amount-keyboard.styles'; @@ -44,7 +47,8 @@ export const AmountKeyboard = ({ const { gotToSendScreen } = useSendScreenNavigation(); const { isMaxAmountSupported, getPercentageAmount } = usePercentageAmount(); const { amountError, validateNonEvmAmountAsync } = useAmountValidation(); - const { asset, updateValue } = useSendContext(); + const { asset, updateValue, updateTo } = useSendContext(); + const { handleSubmitPress } = useSendActions(); const { isNonEvmSendType } = useSendType(); const isNFT = asset?.standard === TokenStandard.ERC1155; const { styles } = useStyles(styleSheet, { @@ -54,6 +58,10 @@ export const AmountKeyboard = ({ const { captureAmountSelected, setAmountInputMethodPressedMax } = useAmountSelectionMetrics(); + const { predefinedRecipient } = useParams<{ + predefinedRecipient: PredefinedRecipient; + }>(); + const updateToPercentageAmount = useCallback( (percentage: number) => { const percentageAmount = getPercentageAmount(percentage) ?? '0'; @@ -100,12 +108,21 @@ export const AmountKeyboard = ({ } } captureAmountSelected(); + // Skip the recipient screen if a predefined recipient is provided + if (predefinedRecipient) { + updateTo(predefinedRecipient.address); + handleSubmitPress(predefinedRecipient.address); + return; + } gotToSendScreen(Routes.SEND.RECIPIENT); }, [ captureAmountSelected, gotToSendScreen, isNonEvmSendType, validateNonEvmAmountAsync, + handleSubmitPress, + updateTo, + predefinedRecipient, ]); return ( diff --git a/app/components/Views/confirmations/components/send/amount/amount.test.tsx b/app/components/Views/confirmations/components/send/amount/amount.test.tsx index fd3356cde1e..1fb5b8f072c 100644 --- a/app/components/Views/confirmations/components/send/amount/amount.test.tsx +++ b/app/components/Views/confirmations/components/send/amount/amount.test.tsx @@ -76,7 +76,9 @@ jest.mock('@react-navigation/native', () => ({ goBack: jest.fn(), navigate: jest.fn(), }), - useRoute: jest.fn(), + useRoute: () => ({ + params: {}, + }), })); const mockedUseAmountSelectionMetrics = jest.mocked(useAmountSelectionMetrics); diff --git a/app/components/Views/confirmations/components/send/asset/asset.test.tsx b/app/components/Views/confirmations/components/send/asset/asset.test.tsx index f70462e7eec..96df77829dc 100644 --- a/app/components/Views/confirmations/components/send/asset/asset.test.tsx +++ b/app/components/Views/confirmations/components/send/asset/asset.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { AssetType, Nft } from '../../../types/token'; -import { useAccountTokens } from '../../../hooks/send/useAccountTokens'; +import { useSendTokens } from '../../../hooks/send/useSendTokens'; import { useTokenSearch } from '../../../hooks/send/useTokenSearch'; import { useEVMNfts } from '../../../hooks/send/useNfts'; import { useAssetSelectionMetrics } from '../../../hooks/send/metrics/useAssetSelectionMetrics'; @@ -66,8 +66,8 @@ const mockNfts: Nft[] = [ }, ]; -jest.mock('../../../hooks/send/useAccountTokens', () => ({ - useAccountTokens: jest.fn(), +jest.mock('../../../hooks/send/useSendTokens', () => ({ + useSendTokens: jest.fn(), })); jest.mock('../../../hooks/send/useTokenSearch', () => ({ @@ -198,7 +198,7 @@ jest.mock('../../../../../../../locales/i18n', () => ({ }), })); -const mockUseAccountTokens = jest.mocked(useAccountTokens); +const mockUseSendTokens = jest.mocked(useSendTokens); const mockUseTokenSearch = jest.mocked(useTokenSearch); const mockUseEVMNfts = jest.mocked(useEVMNfts); const mockUseAssetSelectionMetrics = jest.mocked(useAssetSelectionMetrics); @@ -212,7 +212,7 @@ describe('Asset', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseAccountTokens.mockReturnValue(mockTokens); + mockUseSendTokens.mockReturnValue(mockTokens); mockUseEVMNfts.mockReturnValue(mockNfts); mockUseTokenSearch.mockReturnValue({ diff --git a/app/components/Views/confirmations/components/send/asset/asset.tsx b/app/components/Views/confirmations/components/send/asset/asset.tsx index 0df5fbe4d42..3a9ce0fc5ce 100644 --- a/app/components/Views/confirmations/components/send/asset/asset.tsx +++ b/app/components/Views/confirmations/components/send/asset/asset.tsx @@ -21,7 +21,7 @@ import { NftList } from '../../nft-list'; import { AssetType } from '../../../types/token'; import { NetworkFilter } from '../../network-filter'; import { useEVMNfts } from '../../../hooks/send/useNfts'; -import { useAccountTokens } from '../../../hooks/send/useAccountTokens'; +import { useSendTokens } from '../../../hooks/send/useSendTokens'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { ScrollView } from 'react-native-gesture-handler'; @@ -40,7 +40,7 @@ export const Asset: React.FC<AssetProps> = (props = {}) => { tokenFilter, } = props; - const originalTokens = useAccountTokens({ includeNoBalance }); + const originalTokens = useSendTokens({ includeNoBalance }); const tokens = useMemo( () => (tokenFilter ? tokenFilter(originalTokens) : originalTokens), diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts index 320ddc6b500..99b703a7093 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts @@ -52,10 +52,15 @@ const NATIVE_TOKEN_MOCK = { balanceRaw: '100', } as NonNullable<ReturnType<typeof useTokenWithBalance>>; -function runHook() { - return renderHookWithProvider(() => useInsufficientPayTokenBalanceAlert(), { - state: merge({}, otherControllersMock), - }); +function runHook( + props: Parameters<typeof useInsufficientPayTokenBalanceAlert>[0] = {}, +) { + return renderHookWithProvider( + () => useInsufficientPayTokenBalanceAlert(props), + { + state: merge({}, otherControllersMock), + }, + ); } describe('useInsufficientPayTokenBalanceAlert', () => { @@ -185,7 +190,37 @@ describe('useInsufficientPayTokenBalanceAlert', () => { title: strings('alert_system.insufficient_pay_token_balance.message'), message: strings( 'alert_system.insufficient_pay_token_balance_fees_no_target.message', - { amount: '$1.21' }, + ), + severity: Severity.Danger, + }, + ]); + }); + + it('returns alert if pay token balance shortfall is negative due to bad exchange rates', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceRaw: '999', + }, + setPayToken: jest.fn(), + }); + + useTransactionPayTotalsMock.mockReturnValue({ + ...TOTALS_MOCK, + sourceAmount: { ...TOTALS_MOCK.sourceAmount, usd: '1.19' }, + }); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([ + { + key: AlertKeys.InsufficientPayTokenFees, + field: RowAlertKey.Amount, + isBlocking: true, + title: strings('alert_system.insufficient_pay_token_balance.message'), + message: strings( + 'alert_system.insufficient_pay_token_balance_fees.message', + { amount: '$1.23' }, ), severity: Severity.Danger, }, @@ -252,6 +287,20 @@ describe('useInsufficientPayTokenBalanceAlert', () => { }, ]); }); + + it('returns no alert if pending amount provided', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceRaw: '999', + }, + setPayToken: jest.fn(), + }); + + const { result } = runHook({ pendingAmountUsd: '1.23' }); + + expect(result.current).toStrictEqual([]); + }); }); describe('for source network fee', () => { @@ -325,5 +374,16 @@ describe('useInsufficientPayTokenBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); + + it('returns no alert if pending amount provided', () => { + useTokenWithBalanceMock.mockReturnValue({ + ...NATIVE_TOKEN_MOCK, + balanceRaw: '99', + } as ReturnType<typeof useTokenWithBalance>); + + const { result } = runHook({ pendingAmountUsd: '1.23' }); + + expect(result.current).toStrictEqual([]); + }); }); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts index e700dc4ae46..753d9c79462 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts @@ -28,6 +28,7 @@ export function useInsufficientPayTokenBalanceAlert({ const formatFiat = useFiatFormatter({ currency: 'usd' }); const isLoading = useIsTransactionPayLoading(); const isSourceGasFeeToken = totals?.fees.isSourceGasFeeToken ?? false; + const isPendingAlert = Boolean(pendingAmountUsd !== undefined); const sourceChainId = payToken?.chainId ?? '0x0'; @@ -86,7 +87,15 @@ export function useInsufficientPayTokenBalanceAlert({ const targetUsdValue = totalAmountUsd.minus(shortfall); const targetUsd = formatFiat(targetUsdValue); - return targetUsdValue.isLessThanOrEqualTo(0) ? undefined : targetUsd; + if (targetUsdValue.isLessThanOrEqualTo(0)) { + return undefined; + } + + if (targetUsdValue.isGreaterThan(totalAmountUsd)) { + return formatFiat(totalAmountUsd); + } + + return targetUsd; }, [balanceUsd, formatFiat, totalAmountUsd, totalSourceAmountUsd]); const totalSourceNetworkFeeRaw = useMemo( @@ -100,18 +109,23 @@ export function useInsufficientPayTokenBalanceAlert({ ); const isInsufficientForFees = useMemo( - () => payToken && totalSourceAmountRaw.isGreaterThan(balanceRaw ?? '0'), - [balanceRaw, payToken, totalSourceAmountRaw], + () => + !isPendingAlert && + payToken && + totalSourceAmountRaw.isGreaterThan(balanceRaw ?? '0'), + [balanceRaw, isPendingAlert, payToken, totalSourceAmountRaw], ); const isInsufficientForSourceNetwork = useMemo( () => payToken && !isPayTokenNative && + !isPendingAlert && !isSourceGasFeeToken && totalSourceNetworkFeeRaw.isGreaterThan(nativeToken?.balanceRaw ?? '0'), [ isPayTokenNative, + isPendingAlert, isSourceGasFeeToken, nativeToken?.balanceRaw, payToken, diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts index 9c10d6fcdc8..698389fa312 100644 --- a/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.test.ts @@ -2,7 +2,6 @@ import { renderHook } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; import { useAccountTokens } from './useAccountTokens'; -import { useSendScope } from './useSendScope'; import { getNetworkBadgeSource } from '../../utils/network'; import { getIntlNumberFormatter } from '../../../../../util/intl'; import { TokenStandard } from '../../types/token'; @@ -14,10 +13,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -jest.mock('./useSendScope', () => ({ - useSendScope: jest.fn(), -})); - jest.mock('../../utils/network', () => ({ getNetworkBadgeSource: jest.fn(), })); @@ -44,7 +39,6 @@ jest.mock('../../../../../selectors/currencyRateController', () => ({ })); const mockUseSelector = jest.mocked(useSelector); -const mockUseSendScope = jest.mocked(useSendScope); const mockGetNetworkBadgeSource = jest.mocked(getNetworkBadgeSource); const mockGetIntlNumberFormatter = jest.mocked(getIntlNumberFormatter); const mockSelectAssetsBySelectedAccountGroup = jest.mocked( @@ -103,10 +97,6 @@ describe('useAccountTokens', () => { return undefined; }); - mockUseSendScope.mockReturnValue({ - isEvmOnly: false, - isSolanaOnly: false, - }); mockGetNetworkBadgeSource.mockReturnValue('network-badge-source'); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockGetIntlNumberFormatter.mockReturnValue(mockFormatter as any); @@ -114,55 +104,27 @@ describe('useAccountTokens', () => { mockIsTestNet.mockReturnValue(false); }); - describe('when no scope filter is applied', () => { - it('returns all assets with balance', () => { - const { result } = renderHook(() => useAccountTokens()); - - expect(result.current).toHaveLength(2); - expect(result.current[0].symbol).toBe('TOKEN1'); - expect(result.current[1].symbol).toBe('SOLTOKEN1'); - }); - - it('filters out assets with zero balance', () => { - const { result } = renderHook(() => useAccountTokens()); + it('returns all assets with balance', () => { + const { result } = renderHook(() => useAccountTokens()); - const symbols = result.current.map((asset) => asset.symbol); - expect(symbols).not.toContain('TOKEN2'); - }); + expect(result.current).toHaveLength(2); + expect(result.current[0].symbol).toBe('TOKEN1'); + expect(result.current[1].symbol).toBe('SOLTOKEN1'); }); - describe('when EVM only scope is applied', () => { - beforeEach(() => { - mockUseSendScope.mockReturnValue({ - isEvmOnly: true, - isSolanaOnly: false, - }); - }); - - it('returns only EVM assets', () => { - const { result } = renderHook(() => useAccountTokens()); + it('filters out assets with zero balance', () => { + const { result } = renderHook(() => useAccountTokens()); - expect(result.current).toHaveLength(1); - expect(result.current[0].symbol).toBe('TOKEN1'); - expect(result.current[0].accountType).toContain('eip155'); - }); + const symbols = result.current.map((asset) => asset.symbol); + expect(symbols).not.toContain('TOKEN2'); }); - describe('when Solana only scope is applied', () => { - beforeEach(() => { - mockUseSendScope.mockReturnValue({ - isEvmOnly: false, - isSolanaOnly: true, - }); - }); - - it('returns only Solana assets', () => { - const { result } = renderHook(() => useAccountTokens()); + it('returns all asset types without filtering by account type', () => { + const { result } = renderHook(() => useAccountTokens()); - expect(result.current).toHaveLength(1); - expect(result.current[0].symbol).toBe('SOLTOKEN1'); - expect(result.current[0].accountType).toContain('solana'); - }); + const accountTypes = result.current.map((asset) => asset.accountType); + expect(accountTypes).toContain('eip155:1/erc20:0xtoken1'); + expect(accountTypes).toContain('solana:mainnet/spl:0xsoltoken1'); }); describe('asset processing', () => { diff --git a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts index 4f4d2de8ecb..611faf062ee 100644 --- a/app/components/Views/confirmations/hooks/send/useAccountTokens.ts +++ b/app/components/Views/confirmations/hooks/send/useAccountTokens.ts @@ -11,33 +11,19 @@ import I18n from '../../../../../../locales/i18n'; import { getIntlNumberFormatter } from '../../../../../util/intl'; import { getNetworkBadgeSource } from '../../utils/network'; import { AssetType, TokenStandard } from '../../types/token'; -import { useSendScope } from './useSendScope'; export function useAccountTokens({ includeNoBalance = false, +}: { + includeNoBalance?: boolean; } = {}): AssetType[] { const assets = useSelector(selectFilteredAssetsBySelectedAccountGroup); - const { isEvmOnly, isSolanaOnly } = useSendScope(); const fiatCurrency = useSelector(selectCurrentCurrency); return useMemo(() => { const flatAssets = Object.values(assets).flat(); - let filteredAssets; - - if (isEvmOnly) { - filteredAssets = flatAssets.filter((asset) => - asset.accountType.includes('eip155'), - ); - } else if (isSolanaOnly) { - filteredAssets = flatAssets.filter((asset) => - asset.accountType.includes('solana'), - ); - } else { - filteredAssets = flatAssets; - } - - const assetsWithBalance = filteredAssets.filter((asset) => { + const assetsWithBalance = flatAssets.filter((asset) => { if (includeNoBalance) { return true; } @@ -85,11 +71,5 @@ export function useAccountTokens({ new BigNumber(a.fiat?.balance || 0), ) || 0, ); - }, [ - assets, - includeNoBalance, - isEvmOnly, - isSolanaOnly, - fiatCurrency, - ]) as unknown as AssetType[]; + }, [assets, includeNoBalance, fiatCurrency]) as unknown as AssetType[]; } diff --git a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts index 69514428dad..10dff45ada7 100644 --- a/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useAmountValidation.test.ts @@ -19,6 +19,10 @@ import { AssetType, TokenStandard } from '../../types/token'; import * as SendContext from '../../context/send-context/send-context'; const MOCK_ADDRESS_1 = '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'; +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => ({}), +})); + describe('validateERC1155Balance', () => { it('return error if amount is greater than balance and not otherwise', () => { expect( diff --git a/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts b/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts index c66b2ede1ef..b94feb25d03 100644 --- a/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts +++ b/app/components/Views/confirmations/hooks/send/useGasFeeEstimatesForSend.test.ts @@ -8,16 +8,21 @@ jest.mock('../gas/useGasFeeEstimates', () => ({ }), })); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: () => ({}), +})); + const mockState = { state: evmSendStateMock, }; describe('useGasFeeEstimatesForSend', () => { - it('return gas estimates', () => { + it('returns gas estimates', () => { const { result } = renderHookWithProvider( () => useGasFeeEstimatesForSend(), mockState, ); + expect(result.current.gasFeeEstimates).toBeDefined(); }); }); diff --git a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts index 6d9a9f077ea..71169713adc 100644 --- a/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts +++ b/app/components/Views/confirmations/hooks/send/usePercentageAmount.test.ts @@ -12,6 +12,7 @@ import { useSendContext } from '../../context/send-context'; import * as SendUtils from '../../utils/send'; import { usePercentageAmount } from './usePercentageAmount'; import { useBalance } from './useBalance'; +import { useParams } from '../../../../../util/navigation/navUtils'; jest.mock('@metamask/assets-controllers', () => ({ getNativeTokenAddress: () => '0xeDd1935e28b253C7905Cf5a944f0B5830FFA916a', @@ -31,6 +32,10 @@ jest.mock('./useBalance', () => ({ useBalance: jest.fn(), })); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + const mockState = { state: evmSendStateMock, }; @@ -41,7 +46,14 @@ const mockUseSendContext = useSendContext as jest.MockedFunction< const mockUseBalance = useBalance as jest.MockedFunction<typeof useBalance>; +const mockUseParams = useParams as jest.MockedFunction<typeof useParams>; + describe('usePercentageAmount', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue(undefined); + }); + it('return required fields', () => { mockUseSendContext.mockReturnValue({ asset: {}, diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts new file mode 100644 index 00000000000..40b5feb62cd --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/useSendTokens.test.ts @@ -0,0 +1,262 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useSendTokens } from './useSendTokens'; +import { useAccountTokens } from './useAccountTokens'; +import { useSendType } from './useSendType'; +import { AssetType, TokenStandard } from '../../types/token'; + +jest.mock('./useAccountTokens'); +jest.mock('./useSendType'); + +const mockUseAccountTokens = jest.mocked(useAccountTokens); +const mockUseSendType = jest.mocked(useSendType); + +const mockEvmToken = { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + symbol: 'ETH', + ticker: 'ETH', + decimals: 18, + balance: '1.5', + balanceFiat: '$3000.00', + image: 'https://example.com/eth.png', + aggregators: [], + logo: 'https://example.com/eth.png', + isETH: true, + isNative: true, + accountType: 'eip155:1/erc20:0xtoken1', + networkBadgeSource: 'network-badge-source', + balanceInSelectedCurrency: '$3000.00', + standard: TokenStandard.ERC20, + fiat: { balance: '3000' }, + rawBalance: '0x1234', +} as unknown as AssetType; + +const mockSolanaToken: AssetType = { + address: '0xsolanatoken', + chainId: 'solana:mainnet', + symbol: 'SOL', + ticker: 'SOL', + decimals: 9, + balance: '10.5', + balanceFiat: '$500.00', + image: 'https://example.com/sol.png', + aggregators: [], + logo: 'https://example.com/sol.png', + isNative: true, + accountType: 'solana:mainnet/spl:0xsoltoken1', + networkBadgeSource: 'network-badge-source', + balanceInSelectedCurrency: '$500.00', + standard: TokenStandard.ERC20, + fiat: { balance: '500' }, + rawBalance: '0x5678', +} as unknown as AssetType; + +const mockTronToken: AssetType = { + address: '0xtrontoken', + chainId: 'tron:mainnet', + symbol: 'TRX', + ticker: 'TRX', + decimals: 6, + balance: '100', + balanceFiat: '$200.00', + image: 'https://example.com/trx.png', + aggregators: [], + logo: 'https://example.com/trx.png', + isNative: true, + accountType: 'tron:mainnet/trc20:0xtrontoken1', + networkBadgeSource: 'network-badge-source', + balanceInSelectedCurrency: '$200.00', + standard: TokenStandard.ERC20, + fiat: { balance: '200' }, + rawBalance: '0x9abc', +} as unknown as AssetType; + +const mockBitcoinToken: AssetType = { + address: '0xbtctoken', + chainId: 'bip122:000000000019d6689c085ae165831e93', + symbol: 'BTC', + ticker: 'BTC', + decimals: 8, + balance: '0.5', + balanceFiat: '$25000.00', + image: 'https://example.com/btc.png', + aggregators: [], + logo: 'https://example.com/btc.png', + isNative: true, + accountType: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + networkBadgeSource: 'network-badge-source', + balanceInSelectedCurrency: '$25000.00', + standard: TokenStandard.ERC20, + fiat: { balance: '25000' }, + rawBalance: '0xdef0', +} as unknown as AssetType; + +describe('useSendTokens', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendType.mockReturnValue({ + isEvmSendType: undefined, + isEvmNativeSendType: undefined, + isNonEvmSendType: undefined, + isNonEvmNativeSendType: undefined, + isSolanaSendType: undefined, + isBitcoinSendType: undefined, + isTronSendType: undefined, + }); + }); + + it('returns all tokens when no send type is set', () => { + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + expect(result.current).toHaveLength(4); + }); + + it('filters to EVM tokens when isEvmSendType is true', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: true, + isEvmNativeSendType: undefined, + isNonEvmSendType: undefined, + isNonEvmNativeSendType: undefined, + isSolanaSendType: undefined, + isBitcoinSendType: undefined, + isTronSendType: undefined, + }); + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + expect(result.current).toHaveLength(1); + expect(result.current[0]).toEqual(mockEvmToken); + }); + + it('filters to Solana tokens when isSolanaSendType is true', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: undefined, + isEvmNativeSendType: undefined, + isNonEvmSendType: true, + isNonEvmNativeSendType: undefined, + isSolanaSendType: true, + isBitcoinSendType: undefined, + isTronSendType: undefined, + }); + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + expect(result.current).toHaveLength(1); + expect(result.current[0]).toEqual(mockSolanaToken); + }); + + it('filters to Tron tokens when isTronSendType is true', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: undefined, + isEvmNativeSendType: undefined, + isNonEvmSendType: true, + isNonEvmNativeSendType: undefined, + isSolanaSendType: undefined, + isBitcoinSendType: undefined, + isTronSendType: true, + }); + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + expect(result.current).toHaveLength(1); + expect(result.current[0]).toEqual(mockTronToken); + }); + + it('filters to Bitcoin tokens when isBitcoinSendType is true', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: undefined, + isEvmNativeSendType: undefined, + isNonEvmSendType: true, + isNonEvmNativeSendType: undefined, + isSolanaSendType: undefined, + isBitcoinSendType: true, + isTronSendType: undefined, + }); + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: false, + }); + expect(result.current).toHaveLength(1); + expect(result.current[0]).toEqual(mockBitcoinToken); + }); + + it('passes includeNoBalance option to useAccountTokens', () => { + mockUseAccountTokens.mockReturnValue([mockEvmToken]); + + renderHook(() => useSendTokens({ includeNoBalance: true })); + + expect(mockUseAccountTokens).toHaveBeenCalledWith({ + includeNoBalance: true, + }); + }); + + it('prioritizes first matching account type when multiple are true', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: true, + isEvmNativeSendType: undefined, + isNonEvmSendType: true, + isNonEvmNativeSendType: undefined, + isSolanaSendType: true, + isBitcoinSendType: undefined, + isTronSendType: undefined, + }); + mockUseAccountTokens.mockReturnValue([ + mockEvmToken, + mockSolanaToken, + mockTronToken, + mockBitcoinToken, + ]); + + const { result } = renderHook(() => useSendTokens()); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toEqual(mockEvmToken); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/useSendTokens.ts b/app/components/Views/confirmations/hooks/send/useSendTokens.ts new file mode 100644 index 00000000000..8ca012ef860 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/useSendTokens.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { AssetType } from '../../types/token'; +import { useAccountTokens } from './useAccountTokens'; +import { useSendType } from './useSendType'; + +export function useSendTokens({ + includeNoBalance = false, +}: { + includeNoBalance?: boolean; +} = {}): AssetType[] { + const { isEvmSendType, isSolanaSendType, isTronSendType, isBitcoinSendType } = + useSendType(); + const allTokens = useAccountTokens({ includeNoBalance }); + + return useMemo(() => { + const accountTypeMap: Record<string, boolean> = { + eip155: !!isEvmSendType, + solana: !!isSolanaSendType, + tron: !!isTronSendType, + bip122: !!isBitcoinSendType, + }; + + const matchedAccountType = Object.entries(accountTypeMap).find( + ([, isType]) => isType, + )?.[0]; + + if (!matchedAccountType) { + return allTokens; + } + + return allTokens.filter((token) => + token.accountType?.includes(matchedAccountType), + ); + }, [ + allTokens, + isEvmSendType, + isSolanaSendType, + isTronSendType, + isBitcoinSendType, + ]); +} diff --git a/app/components/Views/confirmations/hooks/send/useSendType.test.ts b/app/components/Views/confirmations/hooks/send/useSendType.test.ts index 2032a0d6a5b..5f9de050566 100644 --- a/app/components/Views/confirmations/hooks/send/useSendType.test.ts +++ b/app/components/Views/confirmations/hooks/send/useSendType.test.ts @@ -1,20 +1,342 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { evmSendStateMock } from '../../__mocks__/send.mock'; +import { + evmSendStateMock, + EVM_NATIVE_ASSET, + SOLANA_ASSET, +} from '../../__mocks__/send.mock'; import { useSendType } from './useSendType'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { useSendContext } from '../../context/send-context'; +import { AssetType } from '../../types/token'; const mockState = { state: evmSendStateMock, }; +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + +jest.mock('../../context/send-context', () => ({ + useSendContext: jest.fn(), +})); + describe('useSendType', () => { - it('return types of send', () => { - const { result } = renderHookWithProvider(() => useSendType(), mockState); - expect(result.current).toEqual({ - isEvmNativeSendType: undefined, - isEvmSendType: undefined, - isNonEvmNativeSendType: undefined, - isNonEvmSendType: undefined, - isSolanaSendType: undefined, + const mockUseParams = jest.mocked(useParams); + const mockUseSendContext = jest.mocked(useSendContext); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue(undefined); + mockUseSendContext.mockReturnValue({ + asset: undefined, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + }); + + describe('with no asset or predefined recipient', () => { + it('returns undefined for all send types', () => { + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current).toEqual({ + isEvmNativeSendType: undefined, + isEvmSendType: undefined, + isNonEvmNativeSendType: undefined, + isNonEvmSendType: undefined, + isSolanaSendType: undefined, + }); + }); + }); + + describe('EVM assets', () => { + it('identifies EVM address as EVM send type', () => { + mockUseSendContext.mockReturnValue({ + asset: { + ...EVM_NATIVE_ASSET, + address: '0x1234567890123456789012345678901234567890', + } as unknown as AssetType, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBe(true); + }); + + it('identifies EVM native asset as EVM native send type', () => { + mockUseSendContext.mockReturnValue({ + asset: EVM_NATIVE_ASSET as unknown as AssetType, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBe(true); + expect(result.current.isEvmNativeSendType).toBe(true); + }); + + it('identifies EVM token as EVM send type but not native', () => { + mockUseSendContext.mockReturnValue({ + asset: { + ...EVM_NATIVE_ASSET, + image: '', + isNative: false, + }, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBe(true); + expect(result.current.isEvmNativeSendType).toBe(false); + }); + }); + + describe('Solana assets', () => { + it('identifies Solana chain ID as Solana send type', () => { + mockUseSendContext.mockReturnValue({ + asset: SOLANA_ASSET, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isSolanaSendType).toBe(true); + expect(result.current.isNonEvmSendType).toBe(true); + }); + + it('identifies Solana native asset as non-EVM native send type', () => { + mockUseSendContext.mockReturnValue({ + asset: SOLANA_ASSET, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isNonEvmNativeSendType).toBe(true); + expect(result.current.isSolanaSendType).toBe(true); + }); + }); + + describe('predefined recipients', () => { + it('identifies predefined EVM recipient as EVM send type', () => { + mockUseParams.mockReturnValue({ + predefinedRecipient: { + address: '0x1234567890123456789012345678901234567890', + chainType: 'evm', + }, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBe(true); + }); + + it('identifies predefined Solana recipient as Solana send type', () => { + mockUseParams.mockReturnValue({ + predefinedRecipient: { + address: '7W54AwGDYRF7Xmoi6phjTnrQhruYtoUdCKJMYAXP7VWC', + chainType: 'solana', + }, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isSolanaSendType).toBe(true); + expect(result.current.isNonEvmSendType).toBe(true); + }); + }); + + describe('EVM vs non-EVM detection', () => { + it('returns undefined for EVM when asset has no address', () => { + mockUseSendContext.mockReturnValue({ + asset: { + ...EVM_NATIVE_ASSET, + address: undefined as unknown as string, + }, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBeUndefined(); + }); + + it('returns undefined for non-EVM when asset has no chainId', () => { + mockUseSendContext.mockReturnValue({ + asset: { + ...SOLANA_ASSET, + chainId: undefined as unknown as string, + }, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isNonEvmSendType).toBeUndefined(); + }); + }); + + describe('native asset detection', () => { + it('returns undefined for native status when asset has no isNative property', () => { + mockUseSendContext.mockReturnValue({ + asset: { + address: '0x1234567890123456789012345678901234567890', + } as unknown as typeof EVM_NATIVE_ASSET, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmNativeSendType).toBeUndefined(); + expect(result.current.isNonEvmNativeSendType).toBeUndefined(); + }); + + it('handles false native status correctly', () => { + mockUseSendContext.mockReturnValue({ + asset: { + ...EVM_NATIVE_ASSET, + isNative: false, + image: '', + }, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmNativeSendType).toBe(false); + }); + }); + + describe('predefined recipient priority', () => { + it('prioritizes predefined EVM over asset address', () => { + mockUseParams.mockReturnValue({ + predefinedRecipient: { + address: '0x1234567890123456789012345678901234567890', + chainType: 'evm', + }, + }); + mockUseSendContext.mockReturnValue({ + asset: SOLANA_ASSET, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isEvmSendType).toBe(true); + }); + + it('prioritizes predefined Solana over asset chainId', () => { + mockUseParams.mockReturnValue({ + predefinedRecipient: { + address: '7W54AwGDYRF7Xmoi6phjTnrQhruYtoUdCKJMYAXP7VWC', + chainType: 'solana', + }, + }); + mockUseSendContext.mockReturnValue({ + asset: EVM_NATIVE_ASSET, + chainId: undefined, + fromAccount: undefined, + from: '', + maxValueMode: false, + to: undefined, + updateAsset: jest.fn(), + updateTo: jest.fn(), + updateValue: jest.fn(), + value: undefined, + }); + + const { result } = renderHookWithProvider(() => useSendType(), mockState); + + expect(result.current.isSolanaSendType).toBe(true); }); }); }); diff --git a/app/components/Views/confirmations/hooks/send/useSendType.ts b/app/components/Views/confirmations/hooks/send/useSendType.ts index 5a574ba7657..b4cb0cbf51f 100644 --- a/app/components/Views/confirmations/hooks/send/useSendType.ts +++ b/app/components/Views/confirmations/hooks/send/useSendType.ts @@ -5,7 +5,7 @@ import { /// END:ONLY_INCLUDE_IF isSolanaChainId, } from '@metamask/bridge-controller'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useSendContext } from '../../context/send-context'; import { @@ -14,34 +14,64 @@ import { isTronChainId, /// END:ONLY_INCLUDE_IF } from '../../../../../core/Multichain/utils'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { PredefinedRecipient } from '../../utils/send'; export const useSendType = () => { const { asset } = useSendContext(); + const { predefinedRecipient } = + useParams<{ + predefinedRecipient: PredefinedRecipient; + }>() || {}; + + const isPredefinedEvm = predefinedRecipient?.chainType === 'evm'; + const isPredefinedBitcoin = predefinedRecipient?.chainType === 'bitcoin'; + const isPredefinedSolana = predefinedRecipient?.chainType === 'solana'; + const isPredefinedTron = predefinedRecipient?.chainType === 'tron'; + + const isPredefinedNonEvm = + predefinedRecipient?.chainType && predefinedRecipient.chainType !== 'evm'; + const isEvmSendType = useMemo( - () => (asset?.address ? isEvmAddress(asset.address) : undefined), - [asset?.address], + () => + isPredefinedEvm || + (asset?.address ? isEvmAddress(asset.address) : undefined), + [asset?.address, isPredefinedEvm], ); + const isNonEvmSendType = useMemo( - () => (asset?.chainId ? isNonEvmChainId(asset.chainId) : undefined), + () => + isPredefinedNonEvm || + (asset?.chainId ? isNonEvmChainId(asset.chainId) : undefined), + [asset?.chainId, isPredefinedNonEvm], + ); + + const createChainTypeCheck = useCallback( + ( + isPredefined: boolean | undefined, + chainChecker: (chainId: string) => boolean, + ) => + isPredefined || + (asset?.chainId ? chainChecker(asset.chainId) : undefined), [asset?.chainId], ); const isSolanaSendType = useMemo( - () => (asset?.chainId ? isSolanaChainId(asset.chainId) : undefined), - [asset?.chainId], + () => createChainTypeCheck(isPredefinedSolana, isSolanaChainId), + [createChainTypeCheck, isPredefinedSolana], ); /// BEGIN:ONLY_INCLUDE_IF(bitcoin) const isBitcoinSendType = useMemo( - () => (asset?.chainId ? isBitcoinChainId(asset.chainId) : undefined), - [asset?.chainId], + () => createChainTypeCheck(isPredefinedBitcoin, isBitcoinChainId), + [createChainTypeCheck, isPredefinedBitcoin], ); /// END:ONLY_INCLUDE_IF /// BEGIN:ONLY_INCLUDE_IF(tron) const isTronSendType = useMemo( - () => (asset?.chainId ? isTronChainId(asset.chainId) : undefined), - [asset?.chainId], + () => createChainTypeCheck(isPredefinedTron, isTronChainId), + [createChainTypeCheck, isPredefinedTron], ); /// END:ONLY_INCLUDE_IF diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts index f459b0cc8fa..fb79267c211 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts @@ -8,11 +8,16 @@ import { } from '../../__mocks__/send.mock'; import { useSendContext } from '../../context/send-context'; import { useToAddressValidation } from './useToAddressValidation'; +import { useParams } from '../../../../../util/navigation/navUtils'; jest.mock('../../context/send-context', () => ({ useSendContext: jest.fn(), })); +jest.mock('../../../../../util/navigation/navUtils', () => ({ + useParams: jest.fn(), +})); + const mockState = { state: evmSendStateMock, }; @@ -21,7 +26,14 @@ const mockUseSendContext = useSendContext as jest.MockedFunction< typeof useSendContext >; +const mockUseParams = jest.mocked(useParams); + describe('useToAddressValidation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue(undefined); + }); + it('return fields for to address error and warning', () => { mockUseSendContext.mockReturnValue( {} as unknown as ReturnType<typeof useSendContext>, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 4c47a4d62f6..11dd534d402 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -342,6 +342,7 @@ describe('useTransactionConfirm', () => { maxPriorityFeePerGas: '0x2', } as unknown as ReturnType<typeof useSelectedGasFeeToken>); }); + it('adds batchTransactions and gas properties when smart transaction is enabled', async () => { const { result } = renderHook(); @@ -380,6 +381,25 @@ describe('useTransactionConfirm', () => { }), }); }); + + it('does nothing if isGasFeeTokenIgnoredIfBalance', async () => { + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + isGasFeeTokenIgnoredIfBalance: true, + } as unknown as TransactionMeta); + + const { result } = renderHook(); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), { + txMeta: expect.not.objectContaining({ + batchTransactions: expect.any(Array), + }), + }); + }); }); describe('handleGasless7702', () => { @@ -438,5 +458,28 @@ describe('useTransactionConfirm', () => { txMeta: expect.not.objectContaining({ isExternalSign: true }), }); }); + + it('does nothing if isGasFeeTokenIgnoredIfBalance', async () => { + isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false)); + + useSelectedGasFeeTokenMock.mockReturnValue({ + transferTransaction: { data: '0xabc' }, + } as unknown as ReturnType<typeof useSelectedGasFeeToken>); + + useTransactionMetadataRequestMock.mockReturnValue({ + id: transactionIdMock, + isGasFeeTokenIgnoredIfBalance: true, + } as unknown as TransactionMeta); + + const { result } = renderHook(); + + await act(async () => { + await result.current.onConfirm(); + }); + + expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), { + txMeta: expect.not.objectContaining({ isExternalSign: true }), + }); + }); }); }); diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index 974530bcbd5..e34b7291f9b 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -33,7 +33,8 @@ export function useTransactionConfirm() { const navigation = useNavigation(); const transactionMetadata = useTransactionMetadataRequest(); const selectedGasFeeToken = useSelectedGasFeeToken(); - const { chainId, type } = transactionMetadata ?? {}; + const { chainId, isGasFeeTokenIgnoredIfBalance, type } = + transactionMetadata ?? {}; const { isFullScreenConfirmation } = useFullScreenConfirmation(); const quotes = useTransactionPayQuotes(); @@ -49,7 +50,7 @@ export function useTransactionConfirm() { const handleSmartTransaction = useCallback( (updatedMetadata: TransactionMeta) => { - if (!selectedGasFeeToken) { + if (!selectedGasFeeToken || isGasFeeTokenIgnoredIfBalance) { return; } @@ -76,6 +77,7 @@ export function useTransactionConfirm() { }, [ selectedGasFeeToken, + isGasFeeTokenIgnoredIfBalance, isGaslessSupported, transactionMetadata?.isGasFeeSponsored, ], @@ -83,7 +85,7 @@ export function useTransactionConfirm() { const handleGasless7702 = useCallback( (updatedMetadata: TransactionMeta) => { - if (!selectedGasFeeToken) { + if (!selectedGasFeeToken || isGasFeeTokenIgnoredIfBalance) { return; } @@ -92,6 +94,7 @@ export function useTransactionConfirm() { isGaslessSupported && transactionMetadata?.isGasFeeSponsored; }, [ + isGasFeeTokenIgnoredIfBalance, isGaslessSupported, selectedGasFeeToken, transactionMetadata?.isGasFeeSponsored, diff --git a/app/components/Views/confirmations/hooks/useSendNavigation.test.ts b/app/components/Views/confirmations/hooks/useSendNavigation.test.ts index 8f3767ba5bd..24eae996af8 100644 --- a/app/components/Views/confirmations/hooks/useSendNavigation.test.ts +++ b/app/components/Views/confirmations/hooks/useSendNavigation.test.ts @@ -35,9 +35,10 @@ describe('useSendNavigation', () => { const { result } = renderHookWithProvider(() => useSendNavigation(), { state: mockState, }); - result.current.navigateToSendPage(InitSendLocation.AssetOverview, { - name: 'ETHEREUM', - } as AssetType); + result.current.navigateToSendPage({ + location: InitSendLocation.AssetOverview, + asset: { name: 'ETHEREUM' } as AssetType, + }); expect(mockNavigate).toHaveBeenCalledWith('SendFlowView'); }); @@ -45,9 +46,10 @@ describe('useSendNavigation', () => { const { result } = renderHookWithProvider(() => useSendNavigation(), { state: rffSendRedesignEnabledMock, }); - result.current.navigateToSendPage(InitSendLocation.AssetOverview, { - name: 'ETHEREUM', - } as AssetType); + result.current.navigateToSendPage({ + location: InitSendLocation.AssetOverview, + asset: { name: 'ETHEREUM' } as AssetType, + }); expect(mockNavigate.mock.calls[0][0]).toEqual('Send'); }); }); diff --git a/app/components/Views/confirmations/hooks/useSendNavigation.ts b/app/components/Views/confirmations/hooks/useSendNavigation.ts index 5062deb3249..9569e4da533 100644 --- a/app/components/Views/confirmations/hooks/useSendNavigation.ts +++ b/app/components/Views/confirmations/hooks/useSendNavigation.ts @@ -1,11 +1,9 @@ -import { Nft } from '@metamask/assets-controllers'; import { useCallback } from 'react'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { selectSendRedesignFlags } from '../../../../selectors/featureFlagController/confirmations'; -import { handleSendPageNavigation } from '../utils/send'; -import { AssetType } from '../types/token'; +import { handleSendPageNavigation, SendNavigationParams } from '../utils/send'; export const useSendNavigation = () => { const { navigate } = useNavigation(); @@ -14,13 +12,11 @@ export const useSendNavigation = () => { ); const navigateToSendPage = useCallback( - (location: string, asset?: AssetType | Nft) => { - handleSendPageNavigation( - navigate, - location, + (params: Omit<SendNavigationParams, 'isSendRedesignEnabled'>) => { + handleSendPageNavigation(navigate, { + ...params, isSendRedesignEnabled, - asset, - ); + }); }, [navigate, isSendRedesignEnabled], ); diff --git a/app/components/Views/confirmations/utils/address.test.ts b/app/components/Views/confirmations/utils/address.test.ts new file mode 100644 index 00000000000..7e6d38570cd --- /dev/null +++ b/app/components/Views/confirmations/utils/address.test.ts @@ -0,0 +1,130 @@ +import { derivePredefinedRecipientParams } from './address'; +import { ChainType } from './send'; + +describe('derivePredefinedRecipientParams', () => { + describe('EVM addresses', () => { + it('returns EVM chain type for valid EVM address', () => { + const address = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.EVM, + }); + }); + }); + + describe('Solana addresses', () => { + it('returns Solana chain type for valid Solana address', () => { + const address = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.SOLANA, + }); + }); + }); + + describe('Bitcoin addresses', () => { + it('returns Bitcoin chain type for valid P2WPKH mainnet address', () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.BITCOIN, + }); + }); + + it('returns Bitcoin chain type for valid P2PKH mainnet address', () => { + const address = '1P5ZEDWTKTFGxQjZphgWPQUpe554WKDfHQ'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.BITCOIN, + }); + }); + }); + + describe('Tron addresses', () => { + it('returns Tron chain type for valid Tron address', () => { + const address = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.TRON, + }); + }); + + it('returns Tron chain type for another valid Tron address', () => { + const address = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toEqual({ + address, + chainType: ChainType.TRON, + }); + }); + }); + + describe('Invalid addresses', () => { + it('returns undefined for invalid address', () => { + const address = 'invalid-address'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + const address = ''; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for address with wrong prefix', () => { + const address = 'ANPeeaaFhwdYaBjwE6tz8N6Vp1y66i5NjE'; + + const result = derivePredefinedRecipientParams(address); + + expect(result).toBeUndefined(); + }); + }); + + describe('Priority order', () => { + it('checks EVM address first', () => { + const evmAddress = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; + const result = derivePredefinedRecipientParams(evmAddress); + expect(result?.chainType).toBe(ChainType.EVM); + }); + + it('checks Solana address after EVM', () => { + const solanaAddress = '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV'; + const result = derivePredefinedRecipientParams(solanaAddress); + expect(result?.chainType).toBe(ChainType.SOLANA); + }); + + it('checks Bitcoin address after Solana', () => { + const btcAddress = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const result = derivePredefinedRecipientParams(btcAddress); + expect(result?.chainType).toBe(ChainType.BITCOIN); + }); + + it('checks Tron address last', () => { + const tronAddress = 'TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7'; + const result = derivePredefinedRecipientParams(tronAddress); + expect(result?.chainType).toBe(ChainType.TRON); + }); + }); +}); diff --git a/app/components/Views/confirmations/utils/address.ts b/app/components/Views/confirmations/utils/address.ts new file mode 100644 index 00000000000..833f3ef660a --- /dev/null +++ b/app/components/Views/confirmations/utils/address.ts @@ -0,0 +1,39 @@ +import { ChainType } from './send'; +import { isAddress as isSolanaAddress } from '@solana/addresses'; +import { isAddress as isEvmAddress } from 'ethers/lib/utils'; +import { + isBtcMainnetAddress, + isTronAddress, +} from '../../../../core/Multichain/utils'; + +export const derivePredefinedRecipientParams = (address: string) => { + if (isEvmAddress(address)) { + return { + address, + chainType: ChainType.EVM, + }; + } + + if (isSolanaAddress(address)) { + return { + address, + chainType: ChainType.SOLANA, + }; + } + + if (isBtcMainnetAddress(address)) { + return { + address, + chainType: ChainType.BITCOIN, + }; + } + + if (isTronAddress(address)) { + return { + address, + chainType: ChainType.TRON, + }; + } + + return undefined; +}; diff --git a/app/components/Views/confirmations/utils/send.test.ts b/app/components/Views/confirmations/utils/send.test.ts index 07dfa9a464d..d8c32158d17 100644 --- a/app/components/Views/confirmations/utils/send.test.ts +++ b/app/components/Views/confirmations/utils/send.test.ts @@ -44,26 +44,24 @@ jest.mock('../../../../lib/ppom/ppom-util', () => ({ describe('handleSendPageNavigation', () => { it('navigates to legacy send page', () => { const mockNavigate = jest.fn(); - handleSendPageNavigation( - mockNavigate, - InitSendLocation.WalletActions, - false, - { + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.WalletActions, + isSendRedesignEnabled: false, + asset: { name: 'ETHEREUM', } as AssetType, - ); + }); expect(mockNavigate).toHaveBeenCalledWith('SendFlowView'); }); it('navigates to send redesign page', () => { const mockNavigate = jest.fn(); - handleSendPageNavigation( - mockNavigate, - InitSendLocation.WalletActions, - true, - { + handleSendPageNavigation(mockNavigate, { + location: InitSendLocation.WalletActions, + isSendRedesignEnabled: true, + asset: { name: 'ETHEREUM', } as AssetType, - ); + }); expect(mockNavigate.mock.calls[0][0]).toEqual('Send'); }); }); diff --git a/app/components/Views/confirmations/utils/send.ts b/app/components/Views/confirmations/utils/send.ts index 669ecf5822e..9753fb5450e 100644 --- a/app/components/Views/confirmations/utils/send.ts +++ b/app/components/Views/confirmations/utils/send.ts @@ -30,6 +30,25 @@ import { AssetType, TokenStandard } from '../types/token'; import { MMM_ORIGIN } from '../constants/confirmations'; import { isNativeToken } from '../utils/generic'; +export enum ChainType { + EVM = 'evm', + SOLANA = 'solana', + BITCOIN = 'bitcoin', + TRON = 'tron', +} + +export interface PredefinedRecipient { + address: string; + chainType: ChainType; +} + +export interface SendNavigationParams { + location: string; + isSendRedesignEnabled: boolean; + asset?: AssetType | Nft; + predefinedRecipient?: PredefinedRecipient; +} + const captureSendStartedEvent = (location: string) => { const { trackEvent } = MetaMetrics.getInstance(); trackEvent( @@ -51,16 +70,49 @@ export function isValidPositiveNumericString(str: string) { return false; } } - +/** + * Navigates to the appropriate send flow screen based on the redesign flag and asset type. + * + * This function handles navigation for both the legacy and redesigned send flows. In the redesigned flow, + * it intelligently determines the starting screen based on whether an asset is provided: + * - No asset: starts at asset selection screen + * - ERC721 NFT: starts at recipient screen (since NFTs are non-divisible) + * - Other assets: starts at amount screen + * + * @param navigate - Navigation function that accepts a screen name and optional params object + * @param params - Object containing the navigation parameters + * @param params.location - Analytics identifier for where the send flow was initiated (e.g., 'wallet', 'token_details') + * @param params.isSendRedesignEnabled - Feature flag indicating whether to use the new send flow or legacy SendFlowView + * @param params.asset - Optional preselected asset (token or NFT) to send. When provided, skips the asset selection screen. + * @param params.predefinedRecipient - Optional recipient with chain information. Should be an object containing: + * - `address`: The recipient's address string + * - `chainType`: One of 'evm', 'solana', 'bitcoin', or 'tron' + * + * @remarks + * The predefinedRecipient is passed through navigation params and can be used by downstream screens + * to pre-populate the recipient field and determine the appropriate chain context for the transaction. + * + * @example + * ```typescript + * handleSendPageNavigation(navigation.navigate, { + * location: 'QRCode', + * isSendRedesignEnabled: true, + * predefinedRecipient: { + * address: '7W54AwGDYRF7X...', + * chainType: 'solana' + * } + * }); + * ``` + */ export const handleSendPageNavigation = ( navigate: <RouteName extends string>( screenName: RouteName, params?: object, ) => void, - location: string, - isSendRedesignEnabled: boolean, - asset?: AssetType | Nft, + params: SendNavigationParams, ) => { + const { location, isSendRedesignEnabled, asset, predefinedRecipient } = + params; if (isSendRedesignEnabled) { captureSendStartedEvent(location); let screen = Routes.SEND.ASSET; @@ -71,10 +123,12 @@ export const handleSendPageNavigation = ( screen = Routes.SEND.AMOUNT; } } + navigate(Routes.SEND.DEFAULT, { screen, params: { asset, + predefinedRecipient, }, }); } else { diff --git a/app/components/hooks/useSendNonEvmAsset.ts b/app/components/hooks/useSendNonEvmAsset.ts index 380f4b57324..e39f3cd047f 100644 --- a/app/components/hooks/useSendNonEvmAsset.ts +++ b/app/components/hooks/useSendNonEvmAsset.ts @@ -40,12 +40,11 @@ export function useSendNonEvmAsset({ const sendNonEvmAsset = useCallback( async (location: string): Promise<boolean> => { if (isSendRedesignEnabled) { - handleSendPageNavigation( - navigation.navigate, + handleSendPageNavigation(navigation.navigate, { location, - true, - asset.address ? (asset as TokenI) : undefined, - ); + isSendRedesignEnabled: true, + asset: asset.address ? (asset as TokenI) : undefined, + }); return true; } diff --git a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts index f792253ca13..86f4721428e 100644 --- a/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts +++ b/app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts @@ -148,6 +148,7 @@ export function getTransactionControllerInitMessenger( 'NetworkController:getEIP1559Compatibility', 'KeyringController:signEip7702Authorization', 'KeyringController:signTypedMessage', + 'RemoteFeatureFlagController:getState', 'TransactionController:addTransaction', 'TransactionController:addTransactionBatch', 'TransactionController:getState', diff --git a/app/util/activity/index.test.ts b/app/util/activity/index.test.ts index cf6fcf77dba..ce8a8f84b06 100644 --- a/app/util/activity/index.test.ts +++ b/app/util/activity/index.test.ts @@ -279,7 +279,7 @@ describe('Activity utils :: filterByAddressAndNetwork', () => { expect(result).toEqual(false); }); - it('should return false if the transaction does not meet the token condition for transfers', () => { + it('returns true for outgoing transfer even when token is not in list', () => { const chainId = '0x1'; const transaction = { chainId, @@ -296,6 +296,32 @@ describe('Activity utils :: filterByAddressAndNetwork', () => { // Empty tokens array so matching token is not found. const tokens = [] as Token[]; + const result = filterByAddressAndNetwork( + transaction, + tokens, + TEST_ADDRESS_ONE, + { '0x1': true }, + ); + expect(result).toEqual(true); + }); + + it('returns false for incoming transfer when token is not in list', () => { + const chainId = '0x1'; + const transaction = { + chainId, + status: TX_SUBMITTED, + txParams: { + from: TEST_ADDRESS_TWO, + to: TEST_ADDRESS_ONE, + }, + isTransfer: true, + transferInformation: { + contractAddress: TEST_ADDRESS_THREE, + }, + } as DeepPartial<TransactionMeta> as TransactionMeta; + // Empty tokens array so matching token is not found. + const tokens = [] as Token[]; + const result = filterByAddressAndNetwork( transaction, tokens, @@ -625,7 +651,7 @@ describe('Activity utils :: filterByAddress', () => { expect(result).toEqual(false); }); - it('returns false for transfer when token is not in list', () => { + it('returns true for outgoing transfer even when token is not in list', () => { const transaction = { status: TX_SUBMITTED, txParams: { @@ -640,6 +666,25 @@ describe('Activity utils :: filterByAddress', () => { const tokens = [] as Token[]; + const result = filterByAddress(transaction, tokens, TEST_ADDRESS_ONE); + expect(result).toEqual(true); + }); + + it('returns false for incoming transfer when token is not in list', () => { + const transaction = { + status: TX_SUBMITTED, + txParams: { + from: TEST_ADDRESS_TWO, + to: TEST_ADDRESS_ONE, + }, + isTransfer: true, + transferInformation: { + contractAddress: TEST_ADDRESS_THREE, + }, + } as DeepPartial<TransactionMeta> as TransactionMeta; + + const tokens = [] as Token[]; + const result = filterByAddress(transaction, tokens, TEST_ADDRESS_ONE); expect(result).toEqual(false); }); diff --git a/app/util/activity/index.ts b/app/util/activity/index.ts index cfb7271fe11..c28291be12c 100644 --- a/app/util/activity/index.ts +++ b/app/util/activity/index.ts @@ -116,14 +116,16 @@ export const filterByAddressAndNetwork = ( condition && tx.status !== TX_UNAPPROVED ) { - return isTransfer + const result = isTransfer ? !!tokens.find(({ address }) => areAddressesEqual( address, transferInformation?.contractAddress ?? '', ), - ) + ) || areAddressesEqual(from, selectedAddress) // Allow if sender is current address : true; + + return result; } return false; @@ -150,14 +152,16 @@ export const filterByAddress = ( isFromOrToSelectedAddress(from, to ?? '', selectedAddress) && tx.status !== TX_UNAPPROVED ) { - return isTransfer + const result = isTransfer ? !!tokens.find(({ address }) => areAddressesEqual( address, transferInformation?.contractAddress ?? '', ), - ) + ) || areAddressesEqual(from, selectedAddress) // Allow if sender is current address : true; + + return result; } return false; diff --git a/app/util/middlewares.js b/app/util/middlewares.js index 0fbf6cb4d21..42ca2bdbda9 100644 --- a/app/util/middlewares.js +++ b/app/util/middlewares.js @@ -75,7 +75,7 @@ export function createLoggerMiddleware(opts) { ) { next((/** @type {Function} */ cb) => { if (res.error) { - const { error, ...resWithoutError } = res; + const { error } = res; if (error) { if (containsUserRejectedError(error.message, error.code)) { trackErrorAsAnalytics( @@ -89,21 +89,11 @@ export function createLoggerMiddleware(opts) { * "message":"Internal JSON-RPC error.", * "data":{"code":-32000,"message":"gas required exceeds allowance (59956966) or always failing transaction"} * } - * This will make the error log to sentry with the title "gas required exceeds allowance (59956966) or always failing transaction" - * making it easier to differentiate each error. + * This will track the error to analytics with the error message for better differentiation. */ - const errorParams = { - message: 'Error in RPC response', - orginalError: error, - res: resWithoutError, - req, - }; - - if (error.data) { - errorParams.data = error.data; - } - - Logger.error(error, errorParams); + const errorMessage = + error.data?.message || error.message || 'Unknown RPC error'; + trackErrorAsAnalytics('Error in RPC response', errorMessage); } } } diff --git a/app/util/middlewares.test.js b/app/util/middlewares.test.js new file mode 100644 index 00000000000..09d921e99eb --- /dev/null +++ b/app/util/middlewares.test.js @@ -0,0 +1,416 @@ +import Logger from './Logger'; +import trackErrorAsAnalytics from './metrics/TrackError/trackErrorAsAnalytics'; +import { + createOriginMiddleware, + containsUserRejectedError, + createLoggerMiddleware, +} from './middlewares'; + +// Mock dependencies +jest.mock('./Logger'); +jest.mock('./metrics/TrackError/trackErrorAsAnalytics'); + +describe('middlewares', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('createOriginMiddleware', () => { + it('appends origin to request', () => { + const origin = 'https://example.com'; + const middleware = createOriginMiddleware({ origin }); + const req = {}; + const res = {}; + const next = jest.fn(); + + middleware(req, res, next); + + expect(req.origin).toBe(origin); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('initializes params as empty array if not present', () => { + const middleware = createOriginMiddleware({ origin: 'https://test.com' }); + const req = {}; + const next = jest.fn(); + + middleware(req, {}, next); + + expect(req.params).toEqual([]); + }); + + it('does not override existing params', () => { + const existingParams = [1, 2, 3]; + const middleware = createOriginMiddleware({ origin: 'https://test.com' }); + const req = { params: existingParams }; + const next = jest.fn(); + + middleware(req, {}, next); + + expect(req.params).toBe(existingParams); + }); + }); + + describe('containsUserRejectedError', () => { + it('returns true for error message containing "user rejected"', () => { + const result = containsUserRejectedError('User rejected the transaction'); + + expect(result).toBe(true); + }); + + it('returns true for error message containing "user denied"', () => { + const result = containsUserRejectedError( + 'MetaMask Message Signature: User denied message signature.', + ); + + expect(result).toBe(true); + }); + + it('returns true for error message containing "user cancelled"', () => { + const result = containsUserRejectedError( + 'User cancelled the transaction', + ); + + expect(result).toBe(true); + }); + + it('returns true for case insensitive user rejection messages', () => { + const upperCaseResult = containsUserRejectedError('USER REJECTED'); + const mixedCaseResult = containsUserRejectedError('UsEr DeNiEd'); + + expect(upperCaseResult).toBe(true); + expect(mixedCaseResult).toBe(true); + }); + + it('returns true for error code 4001', () => { + const result = containsUserRejectedError('Some error', 4001); + + expect(result).toBe(true); + }); + + it('returns true when both message and code indicate user rejection', () => { + const result = containsUserRejectedError('User rejected', 4001); + + expect(result).toBe(true); + }); + + it('returns false for null error message', () => { + const result = containsUserRejectedError(null); + + expect(result).toBe(false); + }); + + it('returns false for undefined error message', () => { + const result = containsUserRejectedError(undefined); + + expect(result).toBe(false); + }); + + it('returns false for non-string error message', () => { + const numberResult = containsUserRejectedError(123); + const objectResult = containsUserRejectedError({}); + const arrayResult = containsUserRejectedError([]); + + expect(numberResult).toBe(false); + expect(objectResult).toBe(false); + expect(arrayResult).toBe(false); + }); + + it('returns false for error message without user rejection phrases', () => { + const result = containsUserRejectedError('Internal JSON-RPC error'); + + expect(result).toBe(false); + }); + + it('returns false for error codes other than 4001', () => { + const result4000 = containsUserRejectedError('Some error', 4000); + const result4002 = containsUserRejectedError('Some error', 4002); + + expect(result4000).toBe(false); + expect(result4002).toBe(false); + }); + + it('returns false when exception occurs during checking', () => { + const errorMessage = { + toLowerCase: () => { + throw new Error('Test error'); + }, + }; + + const result = containsUserRejectedError(errorMessage); + + expect(result).toBe(false); + }); + }); + + describe('createLoggerMiddleware', () => { + let middleware; + const origin = 'https://example.com'; + let req; + let res; + let next; + let callback; + + beforeEach(() => { + middleware = createLoggerMiddleware({ origin }); + req = { method: 'eth_sendTransaction' }; + res = {}; + next = jest.fn((cb) => { + callback = cb; + }); + }); + + describe('when response has no error', () => { + it('logs RPC activity', () => { + res = { result: 'success' }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(Logger.log).toHaveBeenCalledWith( + `RPC (${origin}):`, + req, + '->', + res, + ); + }); + + it('does not log when request is internal', () => { + req.isMetamaskInternal = true; + res = { result: 'success' }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(Logger.log).not.toHaveBeenCalled(); + }); + }); + + describe('when response has user rejection error', () => { + it('tracks user rejection to analytics', () => { + const errorMessage = 'User rejected the transaction'; + res = { + error: { + message: errorMessage, + code: 4001, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response: User rejected', + errorMessage, + ); + expect(Logger.log).toHaveBeenCalledWith( + `RPC (${origin}):`, + req, + '->', + res, + ); + }); + + it('does not log RPC activity for user rejection with isMetamaskInternal', () => { + req.isMetamaskInternal = true; + res = { + error: { + message: 'User denied', + code: 4001, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalled(); + expect(Logger.log).not.toHaveBeenCalled(); + }); + }); + + describe('when response has non-user-rejection error', () => { + it('tracks error with nested data.message to analytics', () => { + const nestedMessage = 'gas required exceeds allowance (59956966)'; + res = { + error: { + code: -32603, + message: 'Internal JSON-RPC error.', + data: { + code: -32000, + message: nestedMessage, + }, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + nestedMessage, + ); + }); + + it('tracks error with top-level message when data.message is missing', () => { + const errorMessage = 'Unrecognized chain ID "0x999"'; + res = { + error: { + code: 4902, + message: errorMessage, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + errorMessage, + ); + }); + + it('tracks error with fallback message when both are missing', () => { + res = { + error: { + code: -32603, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + 'Unknown RPC error', + ); + }); + + it('prioritizes data.message over message', () => { + const nestedMessage = 'Specific nested error'; + res = { + error: { + message: 'Generic error', + data: { + message: nestedMessage, + }, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + nestedMessage, + ); + }); + + it('logs RPC activity after tracking error', () => { + res = { + error: { + message: 'Some error', + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalled(); + expect(Logger.log).toHaveBeenCalledWith( + `RPC (${origin}):`, + req, + '->', + res, + ); + }); + + it('does not log RPC activity when request is internal', () => { + req.isMetamaskInternal = true; + res = { + error: { + message: 'Some error', + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalled(); + expect(Logger.log).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('handles error with empty string message', () => { + res = { + error: { + message: '', + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + 'Unknown RPC error', + ); + }); + + it('handles error with null message', () => { + res = { + error: { + message: null, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + 'Unknown RPC error', + ); + }); + + it('handles error with data but no data.message', () => { + res = { + error: { + message: 'Top level message', + data: { + code: -32000, + }, + }, + }; + + middleware(req, res, next); + callback(jest.fn()); + + expect(trackErrorAsAnalytics).toHaveBeenCalledWith( + 'Error in RPC response', + 'Top level message', + ); + }); + }); + + it('calls next with callback', () => { + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(typeof next.mock.calls[0][0]).toBe('function'); + }); + + it('invokes callback passed to middleware callback', () => { + const cb = jest.fn(); + + middleware(req, res, next); + callback(cb); + + expect(cb).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js index 58d200ac8df..139297e5a4a 100644 --- a/app/util/transactions/index.js +++ b/app/util/transactions/index.js @@ -76,6 +76,7 @@ export const TOKEN_METHOD_TRANSFER = 'transfer'; export const TOKEN_METHOD_APPROVE = 'approve'; export const TOKEN_METHOD_TRANSFER_FROM = 'transferfrom'; export const TOKEN_METHOD_INCREASE_ALLOWANCE = 'increaseAllowance'; +export const TOKEN_METHOD_MINT = 'mint'; export const CONTRACT_METHOD_DEPLOY = 'deploy'; export const CONNEXT_METHOD_DEPOSIT = 'connextdeposit'; export const TOKEN_METHOD_SET_APPROVAL_FOR_ALL = 'setapprovalforall'; @@ -102,6 +103,12 @@ export const CONTRACT_CREATION_SIGNATURE = '0x60a060405260046060527f48302e31'; export const INCREASE_ALLOWANCE_SIGNATURE = '0x39509351'; export const SET_APPROVAL_FOR_ALL_SIGNATURE = '0xa22cb465'; +// Common NFT method signatures +export const SAFE_MINT_SIGNATURE = '0x40c10f19'; // safeMint(address,uint256) +export const MINT_SIGNATURE = '0xa0712d68'; // mint(uint256) +export const MINT_TO_SIGNATURE = '0x3b4b1381'; // mintTo(address) - common in many NFT contracts +export const SAFE_MINT_WITH_DATA = '0x8832e6e3'; // safeMint(address,uint256,bytes) + export const TRANSACTION_TYPES = { APPROVE: 'transaction_approve', INCREASE_ALLOWANCE: 'transaction_increase_allowance', @@ -191,6 +198,7 @@ const actionKeys = { [DOWNGRADE_SMART_ACCOUNT_ACTION_KEY]: strings( 'transactions.smart_account_downgrade', ), + [TOKEN_METHOD_MINT]: strings('transactions.mint'), [TransactionType.stakingClaim]: strings( 'transactions.tx_review_staking_claim', ), @@ -450,6 +458,15 @@ export async function getMethodData(data, networkClientId) { ) { return { name: CONTRACT_METHOD_DEPLOY }; } + // Common NFT mint methods + else if ( + fourByteSignature === normalizeHex(SAFE_MINT_SIGNATURE) || + fourByteSignature === normalizeHex(MINT_SIGNATURE) || + fourByteSignature === normalizeHex(MINT_TO_SIGNATURE) || + fourByteSignature === normalizeHex(SAFE_MINT_WITH_DATA) + ) { + return { name: TOKEN_METHOD_MINT }; + } // If it's a new method, use on-chain method registry try { @@ -556,6 +573,22 @@ export async function getTransactionActionKey(transaction, chainId) { return type; } + // Handle deployContract type explicitly + if (type === TransactionType.deployContract) { + return CONTRACT_METHOD_DEPLOY; + } + + // Handle NFT/collectible transfers - ERC721 and ERC1155 + // tokenMethodTransferFrom is used for ERC721 + // tokenMethodSafeTransferFrom is used for ERC1155 + if ( + type === TransactionType.tokenMethodTransferFrom || + type === TransactionType.tokenMethodSafeTransferFrom || + type === 'transferfrom' // Legacy/fallback check + ) { + return TRANSFER_FROM_ACTION_KEY; + } + if (hasTransactionType(transaction, [TransactionType.predictDeposit])) { return TransactionType.predictDeposit; } @@ -592,6 +625,10 @@ export async function getTransactionActionKey(transaction, chainId) { if (name) return name; } + if (type === TransactionType.contractInteraction) { + return SMART_CONTRACT_INTERACTION_ACTION_KEY; + } + const toSmartContract = transaction.toSmartContract !== undefined ? transaction.toSmartContract @@ -651,6 +688,55 @@ export function isTransactionIncomplete(status) { export async function getActionKey(tx, selectedAddress, ticker, chainId) { const actionKey = await getTransactionActionKey(tx, chainId); + // Handle transferFrom - need to distinguish between NFT and ERC20 + // Both return 'transferfrom' but have different transaction types + if (actionKey === TRANSFER_FROM_ACTION_KEY) { + const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase(); + const selectedAddr = selectedAddress?.toLowerCase(); + const sentByUser = fromAddress === selectedAddr; + + // Check if it's an NFT/collectible transfer (ERC721/ERC1155) + const isNFTTransfer = + tx.type === TransactionType.tokenMethodTransferFrom || + tx.type === TransactionType.tokenMethodSafeTransferFrom; + + if (isNFTTransfer) { + // NFT transfers - show collectible messages + if (sentByUser) { + return strings('transactions.sent_collectible'); + } + return strings('transactions.received_collectible'); + } + + // ERC20 transferFrom - decode actual recipient from transaction data + // tx.txParams.to is the token contract, not the recipient + let toAddress; + try { + // transferFrom has 3 parameters (from, to, amount): 0x + 8 (sig) + 64*3 (params) = 202 chars + if (tx.txParams.data && tx.txParams.data.length >= 202) { + // Decode recipient from transferFrom(from, to, amount) calldata + const [, decodedToAddress] = decodeTransferData( + 'transferFrom', + tx.txParams.data, + ); + toAddress = decodedToAddress?.toLowerCase(); + } + } catch (error) { + // If decoding fails, fall back to transferInformation if available + if (tx.transferInformation?.recipient) { + toAddress = tx.transferInformation.recipient?.toLowerCase(); + } + } + + // Determine direction based on whether user is the recipient + const isRecipient = toAddress && toAddress === selectedAddr; + + if (isRecipient) { + return strings('transactions.received_tokens'); + } + return strings('transactions.sent_tokens'); + } + // Handle token transfers with direction logic (similar to ETH transfers) if (actionKey === SEND_TOKEN_ACTION_KEY) { const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase(); diff --git a/app/util/transactions/index.test.ts b/app/util/transactions/index.test.ts index 04c0bfeb6e3..ae6b293c4fe 100644 --- a/app/util/transactions/index.test.ts +++ b/app/util/transactions/index.test.ts @@ -22,6 +22,12 @@ import { TOKEN_METHOD_TRANSFER, CONTRACT_METHOD_DEPLOY, TOKEN_METHOD_TRANSFER_FROM, + TOKEN_METHOD_MINT, + TRANSFER_FROM_ACTION_KEY, + SAFE_MINT_SIGNATURE, + MINT_SIGNATURE, + MINT_TO_SIGNATURE, + SAFE_MINT_WITH_DATA, calculateEIP1559Times, parseTransactionLegacy, getIsNativeTokenTransferred, @@ -515,6 +521,41 @@ describe('Transactions utils :: getMethodData', () => { ); }); + it('returns mint for safeMint signature', async () => { + const safeMintData = `${SAFE_MINT_SIGNATURE}0000000000000000000000000000000000000000000000000000000000000001`; + + const result = await getMethodData(safeMintData, MOCK_NETWORK_CLIENT_ID); + + expect(result.name).toEqual(TOKEN_METHOD_MINT); + }); + + it('returns mint for mint signature', async () => { + const mintData = `${MINT_SIGNATURE}0000000000000000000000000000000000000000000000000000000000000001`; + + const result = await getMethodData(mintData, MOCK_NETWORK_CLIENT_ID); + + expect(result.name).toEqual(TOKEN_METHOD_MINT); + }); + + it('returns mint for mintTo signature', async () => { + const mintToData = `${MINT_TO_SIGNATURE}000000000000000000000000abcdef1234567890abcdef1234567890abcdef12`; + + const result = await getMethodData(mintToData, MOCK_NETWORK_CLIENT_ID); + + expect(result.name).toEqual(TOKEN_METHOD_MINT); + }); + + it('returns mint for safeMintWithData signature', async () => { + const safeMintWithDataData = `${SAFE_MINT_WITH_DATA}0000000000000000000000000000000000000000000000000000000000000001`; + + const result = await getMethodData( + safeMintWithDataData, + MOCK_NETWORK_CLIENT_ID, + ); + + expect(result.name).toEqual(TOKEN_METHOD_MINT); + }); + it('calls handleMethodData with the correct data', async () => { (handleMethodData as jest.Mock).mockResolvedValue({ parsedRegistryMethod: { name: TOKEN_METHOD_TRANSFER }, @@ -846,6 +887,143 @@ describe('Transactions utils :: getActionKey', () => { expect(result).toBe(strings('transactions.sent_ether')); }); + + it('returns "Sent Collectible" for tokenMethodTransferFrom type when user is sender', async () => { + spyOnQueryMethod(undefined); + const tx = { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: MOCK_ADDRESS1, + to: MOCK_ADDRESS2, + }, + }; + + const result = await getActionKey( + tx, + MOCK_ADDRESS1, + undefined, + MOCK_CHAIN_ID, + ); + + expect(result).toBe(strings('transactions.sent_collectible')); + }); + + it('returns "Received Collectible" for tokenMethodTransferFrom type when user is receiver', async () => { + spyOnQueryMethod(undefined); + const tx = { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: MOCK_ADDRESS2, + to: MOCK_ADDRESS1, + }, + }; + + const result = await getActionKey( + tx, + MOCK_ADDRESS1, + undefined, + MOCK_CHAIN_ID, + ); + + expect(result).toBe(strings('transactions.received_collectible')); + }); + + it('returns "Sent Collectible" for tokenMethodSafeTransferFrom type when user is sender', async () => { + spyOnQueryMethod(undefined); + const tx = { + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { + from: MOCK_ADDRESS1, + to: MOCK_ADDRESS2, + }, + }; + + const result = await getActionKey( + tx, + MOCK_ADDRESS1, + undefined, + MOCK_CHAIN_ID, + ); + + expect(result).toBe(strings('transactions.sent_collectible')); + }); + + it('returns "Received Collectible" for tokenMethodSafeTransferFrom type when user is receiver', async () => { + spyOnQueryMethod(undefined); + const tx = { + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { + from: MOCK_ADDRESS2, + to: MOCK_ADDRESS1, + }, + }; + + const result = await getActionKey( + tx, + MOCK_ADDRESS1, + undefined, + MOCK_CHAIN_ID, + ); + + expect(result).toBe(strings('transactions.received_collectible')); + }); + + it('decodes recipient from ERC20 transferFrom transaction data', async () => { + spyOnQueryMethod(undefined); + const sender = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const recipient = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const tokenContract = '0x6b175474e89094c44da98b954eedeac495271d0f'; + + // transferFrom(from, to, amount) calldata + const transferFromData = + '0x23b872dd' + // transferFrom signature + '000000000000000000000000' + + sender.slice(2).toLowerCase() + // from + '000000000000000000000000' + + recipient.slice(2).toLowerCase() + // to (recipient - NOT txParams.to which is the contract) + '0000000000000000000000000000000000000000000000000de0b6b3a7640000'; // amount + + const tx = { + txParams: { + from: sender, + to: tokenContract, // This is the token contract, not the recipient + data: transferFromData, + }, + }; + + // User is the recipient - should show received + const result = await getActionKey(tx, recipient, undefined, MOCK_CHAIN_ID); + + expect(result).toBe(strings('transactions.received_tokens')); + }); + + it('returns sent for ERC20 transferFrom when user is sender', async () => { + spyOnQueryMethod(undefined); + const sender = '0x1440ec793ae50fa046b95bfeca5af475b6003f9e'; + const recipient = '0x77648f1407986479fb1fa5cc3597084b5dbdb057'; + const tokenContract = '0x6b175474e89094c44da98b954eedeac495271d0f'; + + const transferFromData = + '0x23b872dd' + + '000000000000000000000000' + + sender.slice(2).toLowerCase() + + '000000000000000000000000' + + recipient.slice(2).toLowerCase() + + '0000000000000000000000000000000000000000000000000de0b6b3a7640000'; + + const tx = { + txParams: { + from: sender, + to: tokenContract, + data: transferFromData, + }, + }; + + // User is the sender - should show sent + const result = await getActionKey(tx, sender, undefined, MOCK_CHAIN_ID); + + expect(result).toBe(strings('transactions.sent_tokens')); + }); }); describe('Transactions utils :: generateTxWithNewTokenAllowance', () => { @@ -1445,6 +1623,71 @@ describe('Transactions utils :: getTransactionActionKey', () => { expect(actionKey).toBe(type); }); + + it('returns TRANSFER_FROM_ACTION_KEY for tokenMethodTransferFrom type', async () => { + const transaction = { + type: TransactionType.tokenMethodTransferFrom, + txParams: { + to: '0x123', + from: '0x456', + }, + }; + + const actionKey = await getTransactionActionKey(transaction, '0x1'); + + expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY); + }); + + it('returns TRANSFER_FROM_ACTION_KEY for tokenMethodSafeTransferFrom type', async () => { + const transaction = { + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { + to: '0x123', + from: '0x456', + }, + }; + + const actionKey = await getTransactionActionKey(transaction, '0x1'); + + expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY); + }); + + it('returns TRANSFER_FROM_ACTION_KEY for legacy transferfrom type', async () => { + const transaction = { + type: 'transferfrom', + txParams: { + to: '0x123', + from: '0x456', + }, + }; + + const actionKey = await getTransactionActionKey(transaction, '0x1'); + + expect(actionKey).toBe(TRANSFER_FROM_ACTION_KEY); + }); + + it('returns mint for NFT mint method signatures', async () => { + const mintSignatures = [ + SAFE_MINT_SIGNATURE, + MINT_SIGNATURE, + MINT_TO_SIGNATURE, + SAFE_MINT_WITH_DATA, + ]; + + for (const signature of mintSignatures) { + const transaction = { + txParams: { + to: '0x123', + from: '0x456', + data: `${signature}0000000000000000000000000000000000000000000000000000000000000001`, + }, + }; + + const actionKey = await getTransactionActionKey(transaction, '0x1'); + + expect(actionKey).toBe(TOKEN_METHOD_MINT); + } + }); }); describe('Transactions utils :: getFourByteSignature', () => { diff --git a/locales/languages/en.json b/locales/languages/en.json index 6139503ee14..e2958f9be35 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3062,7 +3062,7 @@ "earn": "Earn", "convert": "Convert", "tron": { - "daily_resource": "Daily resource", + "daily_resource": "Daily resources", "bandwidth": "Bandwidth", "energy": "Energy", "daily_resource_description": "This is your daily allowance based on your staked TRX. You get 600 bandwidth for free daily.", @@ -3520,6 +3520,7 @@ "interaction": "Interaction", "contract_deploy": "Contract Deployment", "to_contract": "New Contract", + "mint": "Mint", "tx_details_free": "Free", "tx_details_not_available": "Not available", "smart_contract_interaction": "Smart contract interaction", @@ -6996,6 +6997,7 @@ "low_to_high": "Low to high", "apply": "Apply", "search_placeholder": "Search tokens, sites, URLs", + "cancel": "Cancel", "perps": "Perps", "predictions": "Predictions", "no_results": "No results found", diff --git a/package.json b/package.json index 0203511799f..8d6868a8b57 100644 --- a/package.json +++ b/package.json @@ -176,7 +176,7 @@ "@scure/bip32": "1.7.0", "@metamask/snaps-sdk": "^10.0.0", "react-native@0.76.9": "patch:react-native@npm%3A0.76.9#./.yarn/patches/react-native-npm-0.76.9-1c25352097.patch", - "@metamask/transaction-controller@npm:^62.2.0": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-controller@npm:^62.3.0": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -286,8 +286,8 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", "@metamask/token-search-discovery-controller": "^4.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/transaction-pay-controller": "^10.0.0", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", + "@metamask/transaction-pay-controller": "^10.1.0", "@metamask/tron-wallet-snap": "^1.10.0", "@metamask/utils": "^11.8.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index d7ac51a651b..2cfdd0a5b63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7408,6 +7408,58 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controllers@npm:^91.0.0": + version: 91.0.0 + resolution: "@metamask/assets-controllers@npm:91.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.3" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-sdk": "npm:^9.0.0" + "@metamask/snaps-utils": "npm:^11.0.0" + "@metamask/utils": "npm:^11.8.1" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bitcoin-address-validation: "npm:^2.2.3" + bn.js: "npm:^5.2.1" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + multiformats: "npm:^9.9.0" + reselect: "npm:^5.1.1" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/account-tree-controller": ^4.0.0 + "@metamask/accounts-controller": ^35.0.0 + "@metamask/approval-controller": ^8.0.0 + "@metamask/core-backend": ^5.0.0 + "@metamask/keyring-controller": ^25.0.0 + "@metamask/network-controller": ^26.0.0 + "@metamask/permission-controller": ^12.0.0 + "@metamask/phishing-controller": ^16.0.0 + "@metamask/preferences-controller": ^22.0.0 + "@metamask/providers": ^22.0.0 + "@metamask/snaps-controllers": ^14.0.0 + "@metamask/transaction-controller": ^62.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/8e43d631a5ae86fc4801912e79d944ad087a605bb7a5e2813de64b6e068dc26482d25d37c3e2272e435a71ee8a7dafe875edb46cbfbcd150fb474588b45e6ff4 + languageName: node + linkType: hard + "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch": version: 89.0.1 resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch::version=89.0.1&hash=6be0d3" @@ -7561,6 +7613,38 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-controller@npm:^63.0.0": + version: 63.0.0 + resolution: "@metamask/bridge-controller@npm:63.0.0" + dependencies: + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/constants": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/gas-fee-controller": "npm:^26.0.0" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-network-controller": "npm:^3.0.0" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/utils": "npm:^11.8.1" + bignumber.js: "npm:^9.1.2" + reselect: "npm:^5.1.1" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^35.0.0 + "@metamask/assets-controllers": ^91.0.0 + "@metamask/network-controller": ^26.0.0 + "@metamask/remote-feature-flag-controller": ^2.0.0 + "@metamask/snaps-controllers": ^14.0.0 + "@metamask/transaction-controller": ^62.0.0 + checksum: 10/53125ecf3938d8ec7c5b33b6ee7302b475616f73c24e2303a702fc31b446f9b7335adaf9451edc7070989542595dc6ac4199fd09d94f614e196e8066edc2855e + languageName: node + linkType: hard + "@metamask/bridge-status-controller@npm:^61.0.0": version: 61.0.0 resolution: "@metamask/bridge-status-controller@npm:61.0.0" @@ -7583,6 +7667,28 @@ __metadata: languageName: node linkType: hard +"@metamask/bridge-status-controller@npm:^63.0.0": + version: 63.0.0 + resolution: "@metamask/bridge-status-controller@npm:63.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/polling-controller": "npm:^16.0.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.8.1" + bignumber.js: "npm:^9.1.2" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^35.0.0 + "@metamask/bridge-controller": ^63.0.0 + "@metamask/gas-fee-controller": ^26.0.0 + "@metamask/network-controller": ^26.0.0 + "@metamask/snaps-controllers": ^14.0.0 + "@metamask/transaction-controller": ^62.0.0 + checksum: 10/04a814032f57d988f8b753c789ebd9293ee6205825d92b15d41c9fd9f11e39835d5920186e5a4bede8231d0af26b391e699def6a95be21e04c231d1761c821b6 + languageName: node + linkType: hard + "@metamask/browser-passworder@npm:^4.3.0": version: 4.3.0 resolution: "@metamask/browser-passworder@npm:4.3.0" @@ -8553,6 +8659,26 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain-network-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/multichain-network-controller@npm:3.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/keyring-api": "npm:^21.0.0" + "@metamask/keyring-internal-api": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.8.1" + "@solana/addresses": "npm:^2.0.0" + lodash: "npm:^4.17.21" + peerDependencies: + "@metamask/accounts-controller": ^35.0.0 + "@metamask/network-controller": ^26.0.0 + checksum: 10/b167cd4bed12285c1e37f74a681371c453936e1aaa7e1207fb98cd97cbfa8831ca9e96a569a8787caac3f5a831627435e001dc8febaad2e61a142e4298f57d2f + languageName: node + linkType: hard + "@metamask/multichain-transactions-controller@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/multichain-transactions-controller@npm:6.0.0" @@ -9437,9 +9563,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:62.2.0": - version: 62.2.0 - resolution: "@metamask/transaction-controller@npm:62.2.0" +"@metamask/transaction-controller@npm:62.3.0, @metamask/transaction-controller@npm:^62.2.0": + version: 62.3.0 + resolution: "@metamask/transaction-controller@npm:62.3.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9471,7 +9597,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/978884a159300253960443c331422da9355d26f118b6caa59a4924a99ccadae677a41330bf94497f1cb686cc68bea30431220621e4a9d1576652cb5a3489977a + checksum: 10/c6e4024359567692b8d2f29ccca678124692d28dca547a0bdec829d81aeda381f51840d981a047d69ae60fb24033b6a45d22bb1e8751f4de46a4f26733c30278 languageName: node linkType: hard @@ -9513,9 +9639,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": - version: 62.2.0 - resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.2.0&hash=1a3342" +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": + version: 62.3.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.3.0&hash=1a3342" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9547,34 +9673,33 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/aeac45f777786d040d4860cd49fc2665a632cd834eef3f5ef73e1a7fad95ead6bfb02a597a0b60f9bcc7036c0a88fa0affe27a5d9f11f595cb3cffdc13a446d4 + checksum: 10/a8d7360285485c1d81b1eed2e7b744d82941450fe0825d195bc69d830576a5587624841a7e9f4e75169be2e77db4cde714ca4b35fc04ef6039625de1250849d6 languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^10.0.0": - version: 10.0.0 - resolution: "@metamask/transaction-pay-controller@npm:10.0.0" +"@metamask/transaction-pay-controller@npm:^10.1.0": + version: 10.1.0 + resolution: "@metamask/transaction-pay-controller@npm:10.1.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" + "@metamask/assets-controllers": "npm:^91.0.0" "@metamask/base-controller": "npm:^9.0.0" + "@metamask/bridge-controller": "npm:^63.0.0" + "@metamask/bridge-status-controller": "npm:^63.0.0" "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^26.0.0" + "@metamask/remote-feature-flag-controller": "npm:^2.0.1" + "@metamask/transaction-controller": "npm:^62.2.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/assets-controllers": ^91.0.0 - "@metamask/bridge-controller": ^63.0.0 - "@metamask/bridge-status-controller": ^63.0.0 - "@metamask/gas-fee-controller": ^26.0.0 - "@metamask/network-controller": ^26.0.0 - "@metamask/remote-feature-flag-controller": ^2.0.0 - "@metamask/transaction-controller": ^62.0.0 - checksum: 10/596b50c04ee658bd16aefc8000d8cdbe2ac04e82636e9029b828c352377ca1e1af6b3c33f129ea28ba966553f513f42988e6ad50bc4edb99c59a68467fe6a1f6 + checksum: 10/59b7b07879b7ea36871906efd5b8c3328c16e72780a02b29a814544b2f970d9e625f286cd43f9e4b08cb8e9bffab66e12b296bfc36161c28b033b3b69093bd91 languageName: node linkType: hard @@ -35612,8 +35737,8 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/token-search-discovery-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.2.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" - "@metamask/transaction-pay-controller": "npm:^10.0.0" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-pay-controller": "npm:^10.1.0" "@metamask/tron-wallet-snap": "npm:^1.10.0" "@metamask/utils": "npm:^11.8.1" "@ngraveio/bc-ur": "npm:^1.1.6"