diff --git a/.cursor/worktrees.json b/.cursor/worktrees.json new file mode 100644 index 000000000000..cbb8fe98f1fc --- /dev/null +++ b/.cursor/worktrees.json @@ -0,0 +1,9 @@ +{ + "setup-worktree": [ + "cp $ROOT_WORKTREE_PATH/.js.env .js.env", + "cp $ROOT_WORKTREE_PATH/.ios.env .ios.env", + "cp $ROOT_WORKTREE_PATH/.android.env .android.env", + "cp $ROOT_WORKTREE_PATH/.e2e.env .e2e.env", + "cp -r $ROOT_WORKTREE_PATH/.cursor/plans .cursor/plans" + ] +} diff --git a/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch b/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch deleted file mode 100644 index dc19783ed9ab..000000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/dist/multi-chain-accounts-service/api-balance-fetcher.cjs b/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -index 6ae7034bd87dfdaa49324fd36a340689df45960e..4719739bf194f5fb8669f382b021b54952294508 100644 ---- a/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -+++ b/dist/multi-chain-accounts-service/api-balance-fetcher.cjs -@@ -150,7 +150,10 @@ class AccountsApiBalanceFetcher { - chains.forEach((chainId) => { - const key = `${address}-${chainId}`; - const existingBalance = nativeBalancesFromAPI.get(key); -- if (!existingBalance) { -+ const isChainIncludedInRequest = chainIds.includes(chainId); -+ const isChainSupported = this.supports(chainId); -+ const shouldZeroOutBalance = !existingBalance && isChainIncludedInRequest && isChainSupported; -+ if (shouldZeroOutBalance) { - // Add zero native balance entry if API succeeded but didn't return one - results.push({ - success: true, -@@ -172,7 +175,10 @@ class AccountsApiBalanceFetcher { - const key = `${account.toLowerCase()}-${tokenLowerCase}-${chainId}`; - const isERC = tokenAddress !== ZERO_ADDRESS; - const existingBalance = nonNativeBalancesFromAPI.get(key); -- if (isERC && !existingBalance) { -+ const isChainIncludedInRequest = chainIds.includes(chainId); -+ const isChainSupported = this.supports(chainId); -+ const shouldZeroOutBalance = !existingBalance && isChainIncludedInRequest && isChainSupported; -+ if (isERC && shouldZeroOutBalance) { - results.push({ - success: true, - value: new bn_js_1.default('0'), diff --git a/patches/redux-persist-filesystem-storage+4.2.0.patch b/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch similarity index 56% rename from patches/redux-persist-filesystem-storage+4.2.0.patch rename to .yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch index 37aa4a293c58..dda65f1830d0 100644 --- a/patches/redux-persist-filesystem-storage+4.2.0.patch +++ b/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch @@ -1,7 +1,7 @@ -diff --git a/node_modules/redux-persist-filesystem-storage/index.d.ts b/node_modules/redux-persist-filesystem-storage/index.d.ts -index b0caa94..76b0442 100644 ---- a/node_modules/redux-persist-filesystem-storage/index.d.ts -+++ b/node_modules/redux-persist-filesystem-storage/index.d.ts +diff --git a/index.d.ts b/index.d.ts +index b0caa94ceaa9afaa6c112947a328887e580f76a2..76b0442a7367f39d8ae9300825815edda5b02c44 100644 +--- a/index.d.ts ++++ b/index.d.ts @@ -12,6 +12,7 @@ declare module 'redux-persist-filesystem-storage' { setItem: ( key: string, @@ -10,24 +10,30 @@ index b0caa94..76b0442 100644 callback?: (error?: Error) => void, ) => Promise -diff --git a/node_modules/redux-persist-filesystem-storage/index.js b/node_modules/redux-persist-filesystem-storage/index.js -index d69afb6..0ca3a25 100644 ---- a/node_modules/redux-persist-filesystem-storage/index.js -+++ b/node_modules/redux-persist-filesystem-storage/index.js -@@ -41,11 +41,14 @@ const FilesystemStorage = { +diff --git a/index.js b/index.js +index d69afb678b3d06760ad59831457cfd5c51fdb89b..8d5ecb060a91f137c865ed21f891b640d3cc65fe 100644 +--- a/index.js ++++ b/index.js +@@ -41,11 +41,19 @@ const FilesystemStorage = { onStorageReady = onStorageReadyFactory(options.storagePath); }, - setItem: (key: string, value: string, callback?: (error: ?Error) => void) => +- ReactNativeBlobUtil.fs + setItem: (key: string, value: string, isIOS: boolean = false, callback?: (error: ?Error) => void) => { - ReactNativeBlobUtil.fs ++ return ReactNativeBlobUtil.fs .writeFile(pathForKey(key), value, options.encoding) - .then(() => callback && callback()) - .catch(error => callback && callback(error)), + .then(() => { + if (isIOS) ReactNativeBlobUtil.ios.excludeFromBackupKey(pathForKey(key)); + callback && callback(); -+ }).catch(error => callback && callback(error)); ++ }).catch((error) => { ++ if (!callback) { ++ throw error; ++ } ++ callback(error); ++ }); + }, getItem: onStorageReady( diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index f5d806594100..19785cc80068 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -531,6 +531,11 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'Failed to load PIN. Please try again.', 'card.password_bottomsheet.description_view_pin': 'Enter your wallet password to view your card PIN.', + 'card.card_home.manage_card_options.cashback': 'Cashback', + 'card.card_home.manage_card_options.cashback_description': + 'Earn 1% back on all spending', + 'card.card_home.manage_card_options.cashback_description_metal': + 'Earn 3% back on all spending', }; return strings[key] || key; }, @@ -5565,4 +5570,187 @@ describe('CardHome Component', () => { expect(cardDetails.holderName).toBe('Jane Smith'); }); }); + + describe('Cashback List Item', () => { + it('displays cashback item for authenticated international user with VERIFIED KYC', () => { + // Given: authenticated international user with verified KYC + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: cashback item is visible + expect( + screen.getByTestId(CardHomeSelectors.CASHBACK_ITEM), + ).toBeOnTheScreen(); + }); + + it('hides cashback item for US users', () => { + // Given: authenticated US user with verified KYC + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'us', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: cashback item is not rendered + expect( + screen.queryByTestId(CardHomeSelectors.CASHBACK_ITEM), + ).not.toBeOnTheScreen(); + }); + + it('hides cashback item when user is not authenticated', () => { + // Given: unauthenticated user + setupMockSelectors({ + isAuthenticated: false, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: false, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + }); + + // When: component renders + render(); + + // Then: cashback item is not rendered + expect( + screen.queryByTestId(CardHomeSelectors.CASHBACK_ITEM), + ).not.toBeOnTheScreen(); + }); + + it('hides cashback item when KYC is not verified', () => { + // Given: authenticated international user with pending KYC + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'PENDING', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: cashback item is not rendered + expect( + screen.queryByTestId(CardHomeSelectors.CASHBACK_ITEM), + ).not.toBeOnTheScreen(); + }); + + it('shows standard cashback description for virtual card', () => { + // Given: authenticated international user with virtual card + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: standard description is shown + expect( + screen.getByText('Earn 1% back on all spending'), + ).toBeOnTheScreen(); + }); + + it('shows metal cashback description for metal card', () => { + // Given: authenticated international user with metal card + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.METAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: component renders + render(); + + // Then: metal description is shown + expect( + screen.getByText('Earn 3% back on all spending'), + ).toBeOnTheScreen(); + }); + + it('navigates to cashback screen on press', () => { + // Given: authenticated international user with verified KYC + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: user taps cashback item + render(); + fireEvent.press(screen.getByTestId(CardHomeSelectors.CASHBACK_ITEM)); + + // Then: navigates to cashback route + expect(mockNavigate).toHaveBeenCalled(); + }); + + it('tracks analytics event on press', () => { + // Given: authenticated international user with verified KYC + setupMockSelectors({ + isAuthenticated: true, + userLocation: 'international', + }); + setupLoadCardDataMock({ + isAuthenticated: true, + isBaanxLoginEnabled: true, + cardDetails: { type: CardType.VIRTUAL }, + isLoading: false, + kycStatus: { verificationState: 'VERIFIED', userId: 'user-123' }, + }); + + // When: user taps cashback item + render(); + fireEvent.press(screen.getByTestId(CardHomeSelectors.CASHBACK_ITEM)); + + // Then: tracks cashback button event + expect(mockTrackEvent).toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 8ddfdbb1168d..9b029c05bf62 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -708,24 +708,6 @@ const CardHome = () => { }); }, [navigation, trackEvent, createEventBuilder, userShippingAddress]); - const isUserEligibleForMetalCard = useMemo( - () => - isMetalCardCheckoutEnabled && - isBaanxLoginEnabled && - isAuthenticated && - userLocation === 'us' && - userShippingAddress && - cardDetails?.type === CardType.VIRTUAL, - [ - isMetalCardCheckoutEnabled, - isBaanxLoginEnabled, - isAuthenticated, - userLocation, - userShippingAddress, - cardDetails, - ], - ); - const showCardDetailsErrorToast = useCallback(() => { toastRef?.current?.showToast({ variant: ToastVariants.Icon, @@ -1044,6 +1026,31 @@ const CardHome = () => { isCardProvisioning, ]); + const isUserEligibleForMetalCard = useMemo( + () => + !isLoading && + !cardSetupState.isKYCPending && + !cardSetupState.needsSetup && + !isCardProvisioning && + isMetalCardCheckoutEnabled && + isBaanxLoginEnabled && + isAuthenticated && + userLocation === 'us' && + userShippingAddress && + cardDetails?.type === CardType.VIRTUAL, + [ + isMetalCardCheckoutEnabled, + isBaanxLoginEnabled, + isAuthenticated, + userLocation, + userShippingAddress, + cardDetails, + isLoading, + cardSetupState, + isCardProvisioning, + ], + ); + useEffect( () => () => { isComponentUnmountedRef.current = true; @@ -1428,6 +1435,19 @@ const CardHome = () => { )} + {isUserEligibleForMetalCard && ( + + )} {isAuthenticated && !isLoading && cardDetails && ( { onPress={navigateToCardPage} testID={CardHomeSelectors.ADVANCED_CARD_MANAGEMENT_ITEM} /> - {isUserEligibleForMetalCard && ( - - )} - {isAuthenticated && kycStatus?.verificationState === 'VERIFIED' && ( - { - trackEvent( - createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) - .addProperties({ - action: CardActions.CASHBACK_BUTTON, - }) - .build(), - ); - navigation.navigate(Routes.CARD.CASHBACK); - }} - testID={CardHomeSelectors.CASHBACK_ITEM} - /> - )} + {isAuthenticated && + kycStatus?.verificationState === 'VERIFIED' && + userLocation !== 'us' && ( + { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + action: CardActions.CASHBACK_BUTTON, + }) + .build(), + ); + navigation.navigate(Routes.CARD.CASHBACK); + }} + testID={CardHomeSelectors.CASHBACK_ITEM} + /> + )} ({ }, })); +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + goBack: mockGoBack, + }), + }; +}); + jest.mock('../../../../../util/theme', () => { const actual = jest.requireActual('../../../../../util/theme'); return { @@ -413,6 +424,51 @@ describe('Cashback Component', () => { ); }); + it('navigates back after successful withdrawal', () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.monitoringStatus = 'success'; + + render(); + + expect(mockGoBack).toHaveBeenCalled(); + }); + + it('does not navigate back when monitoring fails', () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.monitoringStatus = 'failed'; + + render(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not navigate back when status is idle', () => { + mockHookReturn.cashbackWallet = { + id: 'w1', + balance: '10.00', + currency: 'musd', + isWithdrawable: true, + type: 'reward', + }; + mockHookReturn.monitoringStatus = 'idle'; + + render(); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); + it('shows failure toast when monitoring fails', () => { mockHookReturn.cashbackWallet = { id: 'w1', diff --git a/app/components/UI/Card/Views/Cashback/Cashback.tsx b/app/components/UI/Card/Views/Cashback/Cashback.tsx index 28f140a29121..4b1ccf0b42a6 100644 --- a/app/components/UI/Card/Views/Cashback/Cashback.tsx +++ b/app/components/UI/Card/Views/Cashback/Cashback.tsx @@ -5,11 +5,11 @@ import { Text, TextVariant, TextColor, + Skeleton, } from '@metamask/design-system-react-native'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; import { strings } from '../../../../../../locales/i18n'; import Button, { ButtonSize, @@ -26,6 +26,7 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { CardActions } from '../../util/metrics'; import { CashbackSelectors } from './Cashback.testIds'; +import { useNavigation } from '@react-navigation/native'; const CURRENCY_DISPLAY_MAP: Record = { musd: 'mUSD', @@ -45,6 +46,7 @@ const formatAmount = (value: string | number): string => { }; const Cashback: React.FC = () => { + const { goBack } = useNavigation(); const tw = useTailwind(); const theme = useTheme(); const { toastRef } = useContext(ToastContext); @@ -94,8 +96,9 @@ const Cashback: React.FC = () => { iconColor: theme.colors.success.default, hasNoTimeout: false, }); + goBack(); } - }, [monitoringStatus, toastRef, theme]); + }, [monitoringStatus, toastRef, theme, goBack]); useEffect(() => { if (monitoringStatus === 'failed' || monitoringError) { @@ -189,7 +192,7 @@ const Cashback: React.FC = () => { {error ? ( - + { ) : ( diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 203ea947e408..81b8e3a67c16 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -594,8 +594,9 @@ jest.mock('../../components/PerpsNotificationTooltip', () => { }; }); -// Mock network utils - these are external utilities that should be mocked +// Mock network utils - spread actual to satisfy transitive deps (e.g. stakeableTokens.getDecimalChainId), override only what we need jest.mock('../../../../../util/networks', () => ({ + ...jest.requireActual('../../../../../util/networks'), getDefaultNetworkByChainId: jest.fn(() => ({ name: 'Arbitrum' })), getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon' })), })); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx index 6a720aad3e1f..f9ca8342d61a 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx @@ -59,6 +59,7 @@ jest.mock('../../hooks/usePerpsEventTracking'); jest.mock('../../../../../util/address'); jest.mock('../../../../Base/TokenIcon', () => jest.fn(() => null)); jest.mock('../../../../../util/networks', () => ({ + ...jest.requireActual('../../../../../util/networks'), getNetworkImageSource: jest.fn(() => ({ uri: 'network-icon.png' })), })); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts index 3734ea343f7b..3f54bbe8c7c7 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.test.ts @@ -4,9 +4,14 @@ import { TransactionType } from '@metamask/transaction-controller'; import { usePerpsBalanceTokenFilter } from './usePerpsBalanceTokenFilter'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; -import { PERPS_CONSTANTS } from '@metamask/perps-controller'; -import { PERPS_BALANCE_PLACEHOLDER_ADDRESS } from '../constants/perpsConfig'; -import type { AssetType } from '../../../Views/confirmations/types/token'; +import { + type AssetType, + type TokenListItem, + isHighlightedItemOutsideAssetList, +} from '../../../Views/confirmations/types/token'; +import { usePerpsTrading } from './usePerpsTrading'; +import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; +import { usePerpsPaymentToken } from './usePerpsPaymentToken'; jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => key), @@ -16,6 +21,11 @@ jest.mock( '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest', ); jest.mock('./useIsPerpsBalanceSelected'); +jest.mock('./usePerpsTrading'); +jest.mock('../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ + useConfirmNavigation: jest.fn(), +})); +jest.mock('./usePerpsPaymentToken'); jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); @@ -40,9 +50,21 @@ const mockUseIsPerpsBalanceSelected = typeof useIsPerpsBalanceSelected >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUsePerpsTrading = usePerpsTrading as jest.MockedFunction< + typeof usePerpsTrading +>; +const mockUseConfirmNavigation = useConfirmNavigation as jest.MockedFunction< + typeof useConfirmNavigation +>; +const mockUsePerpsPaymentToken = usePerpsPaymentToken as jest.MockedFunction< + typeof usePerpsPaymentToken +>; describe('usePerpsBalanceTokenFilter', () => { const chainId = '0xa4b1'; + const mockDepositWithConfirmation = jest.fn().mockResolvedValue(undefined); + const mockNavigateToConfirmation = jest.fn(); + const mockOnPerpsPaymentTokenChange = jest.fn(); beforeEach(() => { jest.clearAllMocks(); @@ -57,6 +79,15 @@ describe('usePerpsBalanceTokenFilter', () => { } return undefined; }); + mockUsePerpsTrading.mockReturnValue({ + depositWithConfirmation: mockDepositWithConfirmation, + } as unknown as ReturnType); + mockUseConfirmNavigation.mockReturnValue({ + navigateToConfirmation: mockNavigateToConfirmation, + } as unknown as ReturnType); + mockUsePerpsPaymentToken.mockReturnValue({ + onPaymentTokenChange: mockOnPerpsPaymentTokenChange, + } as unknown as ReturnType); }); describe('when transaction is not perpsDepositAndOrder', () => { @@ -76,11 +107,11 @@ describe('usePerpsBalanceTokenFilter', () => { const { result } = renderHook(() => usePerpsBalanceTokenFilter()); const filter = result.current; - const output = filter(inputTokens); + const output: TokenListItem[] = filter(inputTokens); expect(output).toBe(inputTokens); expect(output).toHaveLength(1); - expect(output[0].address).toBe('0xabc'); + expect((output[0] as AssetType).address).toBe('0xabc'); }); it('returns tokens unchanged when transaction meta is undefined', () => { @@ -103,7 +134,7 @@ describe('usePerpsBalanceTokenFilter', () => { } as ReturnType); }); - it('prepends perps balance token with correct shape', () => { + it('prepends highlighted row with perps balance and Add funds button', () => { mockUseSelector.mockReturnValue({ availableBalance: '2000.50', }); @@ -123,23 +154,26 @@ describe('usePerpsBalanceTokenFilter', () => { const output = result.current(inputTokens); expect(output).toHaveLength(2); - const perpsToken = output[0]; - expect(perpsToken.address).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); - expect(perpsToken.tokenId).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); - expect(perpsToken.name).toBe('perps.adjust_margin.perps_balance'); - expect(perpsToken.symbol).toBe('USD'); - expect(perpsToken.balance).toBe('2000.50'); - expect(perpsToken.balanceInSelectedCurrency).toBe('$2000.50'); - expect(perpsToken.decimals).toBe(2); - expect(perpsToken.isETH).toBe(false); - expect(perpsToken.isNative).toBe(false); - expect(perpsToken.isSelected).toBe(true); - expect(perpsToken.description).toBe( - PERPS_CONSTANTS.PerpsBalanceTokenDescription, - ); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true); + const highlightedAction = output[0]; + if (isHighlightedItemOutsideAssetList(highlightedAction)) { + expect(highlightedAction.position).toBe('outside_of_asset_list'); + expect(highlightedAction.name).toBe( + 'perps.adjust_margin.perps_balance', + ); + expect(highlightedAction.name_description).toBe('$2000.50'); + expect(highlightedAction.fiat).toBe('$2000.50'); + expect(highlightedAction.fiat_description).toBe('$2000.50'); + expect(highlightedAction.isSelected).toBe(true); + expect(highlightedAction.actions).toHaveLength(1); + expect(highlightedAction.actions?.[0]?.buttonLabel).toBe( + 'perps.add_funds', + ); + } + expect((output[1] as AssetType).address).toBe('0xusdc'); }); - it('uses availableBalance from perps account', () => { + it('uses availableBalance from perps account in highlighted row', () => { mockUseSelector.mockReturnValue({ availableBalance: '999.99', }); @@ -148,8 +182,12 @@ describe('usePerpsBalanceTokenFilter', () => { const { result } = renderHook(() => usePerpsBalanceTokenFilter()); const output = result.current(inputTokens); - expect(output[0].balance).toBe('999.99'); - expect(output[0].balanceInSelectedCurrency).toBe('$999.99'); + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true); + if (isHighlightedItemOutsideAssetList(output[0])) { + expect(output[0].name_description).toBe('$999.99'); + expect(output[0].fiat).toBe('$999.99'); + } }); it('uses zero balance when perps account is null', () => { @@ -164,8 +202,12 @@ describe('usePerpsBalanceTokenFilter', () => { const { result } = renderHook(() => usePerpsBalanceTokenFilter()); const output = result.current(inputTokens); - expect(output[0].balance).toBe('0'); - expect(output[0].balanceInSelectedCurrency).toBe('$0.00'); + expect(output).toHaveLength(1); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true); + if (isHighlightedItemOutsideAssetList(output[0])) { + expect(output[0].name_description).toBe('$0.00'); + expect(output[0].fiat).toBe('$0.00'); + } }); it('clears isSelected on other tokens when perps balance is selected', () => { @@ -195,8 +237,8 @@ describe('usePerpsBalanceTokenFilter', () => { const { result } = renderHook(() => usePerpsBalanceTokenFilter()); const output = result.current(inputTokens); - expect(output[1].isSelected).toBe(false); - expect(output[2].isSelected).toBe(false); + expect((output[1] as AssetType).isSelected).toBe(false); + expect((output[2] as AssetType).isSelected).toBe(false); }); it('keeps token isSelected when perps balance is not selected', () => { @@ -220,7 +262,7 @@ describe('usePerpsBalanceTokenFilter', () => { const { result } = renderHook(() => usePerpsBalanceTokenFilter()); const output = result.current(inputTokens); - expect(output[1].isSelected).toBe(true); + expect((output[1] as AssetType).isSelected).toBe(true); }); it('filters to only allowlisted tokens when allowlist is set', () => { @@ -253,9 +295,56 @@ describe('usePerpsBalanceTokenFilter', () => { const output = result.current(inputTokens); expect(output).toHaveLength(2); - expect(output[0].address).toBe(PERPS_BALANCE_PLACEHOLDER_ADDRESS); - expect(output[1].address).toBe('0xusdc'); - expect(output[1].symbol).toBe('USDC'); + expect(isHighlightedItemOutsideAssetList(output[0])).toBe(true); + expect((output[1] as AssetType).address).toBe('0xusdc'); + expect((output[1] as AssetType).symbol).toBe('USDC'); + }); + + it('calls navigateToConfirmation and depositWithConfirmation when Add funds is pressed', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '500.00', + }); + const inputTokens: AssetType[] = [ + { + address: '0xusdc', + chainId, + symbol: 'USDC', + name: 'USD Coin', + balance: '100', + } as AssetType, + ]; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(2); + const highlightedAction = output[0]; + expect(isHighlightedItemOutsideAssetList(highlightedAction)).toBe(true); + if (isHighlightedItemOutsideAssetList(highlightedAction)) { + highlightedAction.actions?.[0]?.onPress(); + expect(mockNavigateToConfirmation).toHaveBeenCalledWith({ + stack: expect.any(String), + }); + expect(mockDepositWithConfirmation).toHaveBeenCalledTimes(1); + } + }); + + it('calls onPerpsPaymentTokenChange with null when row action is invoked', () => { + mockUseSelector.mockReturnValue({ + availableBalance: '100.00', + }); + const inputTokens: AssetType[] = []; + + const { result } = renderHook(() => usePerpsBalanceTokenFilter()); + const output = result.current(inputTokens); + + expect(output).toHaveLength(1); + const highlightedAction = output[0]; + expect(isHighlightedItemOutsideAssetList(highlightedAction)).toBe(true); + if (isHighlightedItemOutsideAssetList(highlightedAction)) { + highlightedAction.action(); + expect(mockOnPerpsPaymentTokenChange).toHaveBeenCalledWith(null); + } }); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts index 5d40f1a55fdd..cfb6515e515e 100644 --- a/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts +++ b/app/components/UI/Perps/hooks/usePerpsBalanceTokenFilter.ts @@ -1,22 +1,25 @@ import { TransactionType } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; +import perpsPayTokenIcon from 'images/perps-pay-token-icon.png'; import { useCallback } from 'react'; import { Image } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../locales/i18n'; import useFiatFormatter from '../../../UI/SimulationDetails/FiatDisplay/useFiatFormatter'; import { useTransactionMetadataRequest } from '../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest'; -import { AssetType } from '../../../Views/confirmations/types/token'; -import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; -import perpsPayTokenIcon from 'images/perps-pay-token-icon.png'; -import { PERPS_CONSTANTS } from '@metamask/perps-controller'; import { - PERPS_BALANCE_CHAIN_ID, - PERPS_BALANCE_PLACEHOLDER_ADDRESS, -} from '../constants/perpsConfig'; + AssetType, + HighlightedItem, + type TokenListItem, +} from '../../../Views/confirmations/types/token'; +import { hasTransactionType } from '../../../Views/confirmations/utils/transaction'; import { selectPerpsPayWithAnyTokenAllowlistAssets } from '../selectors/featureFlags'; import { selectPerpsAccountState } from '../selectors/perpsController'; import { useIsPerpsBalanceSelected } from './useIsPerpsBalanceSelected'; +import { usePerpsPaymentToken } from './usePerpsPaymentToken'; +import Routes from '../../../../constants/navigation/Routes'; +import { usePerpsTrading } from './usePerpsTrading'; +import { useConfirmNavigation } from '../../../Views/confirmations/hooks/useConfirmNavigation'; /** URI for the perps balance token icon, shared with PerpsPayRow and pay-with modal. */ const resolvedPerpsIcon = Image.resolveAssetSource(perpsPayTokenIcon); @@ -32,7 +35,7 @@ export const PERPS_BALANCE_ICON_URI = resolvedPerpsIcon?.uri ?? ''; */ export function usePerpsBalanceTokenFilter(): ( tokens: AssetType[], -) => AssetType[] { +) => TokenListItem[] { const transactionMeta = useTransactionMetadataRequest(); const isPerpsBalanceSelected = useIsPerpsBalanceSelected(); const perpsAccount = useSelector(selectPerpsAccountState); @@ -41,8 +44,25 @@ export function usePerpsBalanceTokenFilter(): ( ); const formatFiat = useFiatFormatter({ currency: 'usd' }); + const { depositWithConfirmation } = usePerpsTrading(); + const { navigateToConfirmation } = useConfirmNavigation(); + + const isPerpsDepositAndOrder = hasTransactionType(transactionMeta, [ + TransactionType.perpsDepositAndOrder, + ]); + + const handlePerpsDepositPress = useCallback(() => { + navigateToConfirmation({ stack: Routes.PERPS.ROOT }); + depositWithConfirmation().catch(() => { + // Deposit flow handles errors (e.g. user rejection). + }); + }, [navigateToConfirmation, depositWithConfirmation]); + + const { onPaymentTokenChange: onPerpsPaymentTokenChange } = + usePerpsPaymentToken(); + const filterAllowedTokens = useCallback( - (tokens: AssetType[]): AssetType[] => { + (tokens: AssetType[]): TokenListItem[] => { if ( !hasTransactionType(transactionMeta, [ TransactionType.perpsDepositAndOrder, @@ -51,8 +71,6 @@ export function usePerpsBalanceTokenFilter(): ( return tokens; } - const chainId = PERPS_BALANCE_CHAIN_ID; - const availableBalance = perpsAccount?.availableBalance || '0'; const balanceInSelectedCurrency = formatFiat( new BigNumber(availableBalance), @@ -60,23 +78,6 @@ export function usePerpsBalanceTokenFilter(): ( const perpsBalanceName = strings('perps.adjust_margin.perps_balance'); - const perpsBalanceToken: AssetType = { - address: PERPS_BALANCE_PLACEHOLDER_ADDRESS, - chainId, - tokenId: PERPS_BALANCE_PLACEHOLDER_ADDRESS, - name: perpsBalanceName, - symbol: PERPS_CONSTANTS.PerpsBalanceTokenSymbol, - balance: availableBalance, - balanceInSelectedCurrency, - image: PERPS_BALANCE_ICON_URI, - logo: PERPS_BALANCE_ICON_URI, - decimals: 2, - isETH: false, - isNative: false, - isSelected: isPerpsBalanceSelected, - description: PERPS_CONSTANTS.PerpsBalanceTokenDescription, - }; - let mappedTokens = tokens.map((token) => ({ ...token, isSelected: @@ -91,14 +92,38 @@ export function usePerpsBalanceTokenFilter(): ( }); } - return [perpsBalanceToken, ...mappedTokens]; + if (!isPerpsDepositAndOrder) { + return mappedTokens; + } + + const highlightedAction: HighlightedItem = { + position: 'outside_of_asset_list', + icon: PERPS_BALANCE_ICON_URI, + name: perpsBalanceName, + name_description: balanceInSelectedCurrency, + fiat: balanceInSelectedCurrency, + fiat_description: balanceInSelectedCurrency, + isSelected: isPerpsBalanceSelected, + action: () => onPerpsPaymentTokenChange(null), + actions: [ + { + buttonLabel: strings('perps.add_funds'), + onPress: handlePerpsDepositPress, + }, + ], + }; + + return [highlightedAction, ...mappedTokens]; }, [ - transactionMeta, - isPerpsBalanceSelected, - perpsAccount?.availableBalance, + handlePerpsDepositPress, + isPerpsDepositAndOrder, allowListAssets, formatFiat, + onPerpsPaymentTokenChange, + isPerpsBalanceSelected, + perpsAccount?.availableBalance, + transactionMeta, ], ); diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index 52609200676b..59c9818892b1 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -318,6 +318,7 @@ abstract class StreamChannel { subscriber.timer = undefined; } subscriber.pendingUpdate = undefined; + subscriber.hasReceivedFirstUpdate = false; }); // Disconnect the old WebSocket subscription to stop receiving old account data diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts index 2d36d84e60e5..1bf10deac699 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.test.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.test.ts @@ -94,6 +94,7 @@ jest.mock('react-native-background-timer', () => ({ })); // Import non-singleton modules first +import { addEventListener as mockNetInfoAddEventListener } from '@react-native-community/netinfo'; import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../core/Engine'; import { store } from '../../../../store'; @@ -128,13 +129,20 @@ const resetManager = (manager: unknown) => { isInGracePeriod: boolean; gracePeriodTimer: number | null; hasPreloaded: boolean; + isPreloading: boolean; prewarmCleanups: (() => void)[]; - lastBalanceUpdateTime: number; + netInfoUnsubscribe: (() => void) | null; + wasOffline: boolean; }; // Call unsubscribe if it exists before resetting if (m.unsubscribeFromStore) { m.unsubscribeFromStore(); } + if (m.netInfoUnsubscribe) { + m.netInfoUnsubscribe(); + m.netInfoUnsubscribe = null; + } + m.wasOffline = false; // Clean up any prewarm subscriptions m.prewarmCleanups.forEach((cleanup) => cleanup()); m.prewarmCleanups = []; @@ -155,7 +163,7 @@ const resetManager = (manager: unknown) => { m.isInGracePeriod = false; m.gracePeriodTimer = null; m.hasPreloaded = false; - m.lastBalanceUpdateTime = 0; + m.isPreloading = false; }; describe('PerpsConnectionManager', () => { @@ -209,7 +217,7 @@ describe('PerpsConnectionManager', () => { }); describe('getInstance', () => { - it('should return the same instance when called multiple times', () => { + it('returns the same singleton instance on repeated calls', () => { const instance1 = PerpsConnectionManager; const instance2 = PerpsConnectionManager; @@ -218,7 +226,7 @@ describe('PerpsConnectionManager', () => { }); describe('connect', () => { - it('should initialize providers and connect successfully', async () => { + it('initializes providers and connects on first call', async () => { mockPerpsController.init.mockResolvedValueOnce(); await PerpsConnectionManager.connect(); @@ -229,7 +237,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should increment reference count on each connect call', async () => { + it('increments reference count on each connect call', async () => { mockPerpsController.init.mockResolvedValue(); await PerpsConnectionManager.connect(); @@ -243,7 +251,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should return existing promise if already connecting', async () => { + it('returns existing promise when connection is already in progress', async () => { // This test verifies that concurrent connect calls share the same promise // Track promises from both connect calls @@ -274,7 +282,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should handle connection failures', async () => { + it('sets error state and resets flags when connection fails', async () => { const error = new Error('Connection failed'); mockPerpsController.init.mockRejectedValueOnce(error); @@ -294,7 +302,7 @@ describe('PerpsConnectionManager', () => { expect(state.error).toBe('Connection failed'); }); - it('should skip reconnection if already connected', async () => { + it('skips reconnection when already connected', async () => { // First successful connection mockPerpsController.init.mockResolvedValueOnce(); await PerpsConnectionManager.connect(); @@ -308,7 +316,7 @@ describe('PerpsConnectionManager', () => { expect(mockPerpsController.init).toHaveBeenCalledTimes(1); }); - it('should handle rapid disconnect-connect cycles with grace period', async () => { + it('cancels grace period timer when reconnecting during grace period', async () => { // Setup initial connection mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); @@ -345,7 +353,7 @@ describe('PerpsConnectionManager', () => { mockPerpsController.disconnect.mockResolvedValue(); }); - it('should decrement reference count on disconnect', async () => { + it('decrements reference count on disconnect', async () => { await PerpsConnectionManager.connect(); await PerpsConnectionManager.connect(); @@ -359,7 +367,7 @@ describe('PerpsConnectionManager', () => { expect(mockPerpsController.disconnect).not.toHaveBeenCalled(); }); - it('should only start grace period when reference count reaches zero', async () => { + it('starts grace period only when reference count reaches zero', async () => { await PerpsConnectionManager.connect(); await PerpsConnectionManager.connect(); @@ -374,7 +382,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should handle disconnect gracefully with grace period', async () => { + it('starts grace period timer on disconnect instead of disconnecting immediately', async () => { await PerpsConnectionManager.connect(); // Disconnect should not throw even if controller would fail later @@ -386,7 +394,7 @@ describe('PerpsConnectionManager', () => { expect(mockPerpsController.disconnect).not.toHaveBeenCalled(); }); - it('should prevent negative reference count', async () => { + it('prevents reference count from going below zero', async () => { await PerpsConnectionManager.disconnect(); await PerpsConnectionManager.disconnect(); @@ -401,7 +409,7 @@ describe('PerpsConnectionManager', () => { expect(state.isConnected).toBe(false); }); - it('should maintain connection state during grace period', async () => { + it('maintains connected state during grace period', async () => { await PerpsConnectionManager.connect(); const connectedState = PerpsConnectionManager.getConnectionState(); @@ -418,7 +426,7 @@ describe('PerpsConnectionManager', () => { expect(gracePeriodState.isInGracePeriod).toBe(true); }); - it('should maintain state monitoring during grace period', async () => { + it('maintains state monitoring during grace period', async () => { // Connect to set up monitoring await PerpsConnectionManager.connect(); @@ -444,7 +452,7 @@ describe('PerpsConnectionManager', () => { }); describe('getConnectionState', () => { - it('should return initial state', () => { + it('returns initial disconnected state', () => { const state = PerpsConnectionManager.getConnectionState(); expect(state).toEqual({ @@ -457,7 +465,7 @@ describe('PerpsConnectionManager', () => { }); }); - it('should return connecting state during connection', async () => { + it('returns connecting state while connection is in progress', async () => { mockPerpsController.init.mockImplementation( () => new Promise((resolve) => setTimeout(resolve, 100)), ); @@ -556,7 +564,7 @@ describe('PerpsConnectionManager', () => { }); describe('concurrent operations', () => { - it('should handle multiple concurrent connect/disconnect operations', async () => { + it('serializes concurrent connect and disconnect operations', async () => { mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); mockPerpsController.disconnect.mockResolvedValue(); @@ -595,7 +603,7 @@ describe('PerpsConnectionManager', () => { (store.getState as jest.Mock).mockReturnValue({}); }); - it('should set up Redux store subscription on first connect', async () => { + it('sets up Redux store subscription on first connect', async () => { // Connect to trigger monitoring setup mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); @@ -610,7 +618,7 @@ describe('PerpsConnectionManager', () => { expect(typeof storeCallbacks[storeCallbacks.length - 1]).toBe('function'); }); - it('should detect account changes and trigger reconnection', async () => { + it('detects account changes and triggers reconnection', async () => { // Setup connected state mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); @@ -645,7 +653,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should detect network changes and trigger reconnection', async () => { + it('detects network changes and triggers reconnection', async () => { // Setup connected state mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); @@ -680,7 +688,7 @@ describe('PerpsConnectionManager', () => { ); }); - it('should continue monitoring during grace period', async () => { + it('continues monitoring state changes during grace period', async () => { // Setup but don't connect mockPerpsController.init.mockResolvedValue(); mockPerpsController.getAccountState.mockResolvedValue({}); @@ -718,7 +726,7 @@ describe('PerpsConnectionManager', () => { mockPerpsController.reconnectWithNewContext.mockResolvedValue(); }); - it('should clear StreamManager caches', async () => { + it('clears all StreamManager caches on reconnection', async () => { // Setup connected state first mockPerpsController.init.mockResolvedValue(); await PerpsConnectionManager.connect(); @@ -739,7 +747,7 @@ describe('PerpsConnectionManager', () => { expect(mockStreamManagerInstance.prices.clearCache).toHaveBeenCalled(); }); - it('should reinitialize controller with new context', async () => { + it('reinitializes controller with new account and network context', async () => { mockPerpsController.init.mockResolvedValue(); await ( @@ -753,7 +761,7 @@ describe('PerpsConnectionManager', () => { expect(mockPerpsController.init).toHaveBeenCalled(); }); - it('should handle reconnection errors gracefully', async () => { + it('logs error and resets connecting flag when reconnection fails', async () => { const error = new Error('Reconnection failed'); mockPerpsController.init.mockRejectedValueOnce(error); @@ -840,6 +848,84 @@ describe('PerpsConnectionManager', () => { }); }); + describe('preloadSubscriptions concurrency guard', () => { + it('skips concurrent preload when one is already in flight', async () => { + // Arrange + const m = PerpsConnectionManager as unknown as { + isPreloading: boolean; + hasPreloaded: boolean; + preloadSubscriptions: () => Promise; + }; + m.isPreloading = true; + + // Act + await m.preloadSubscriptions(); + + // Assert + expect(mockStreamManagerInstance.prices.prewarm).not.toHaveBeenCalled(); + }); + + it('allows fresh preload after performReconnection resets isPreloading', async () => { + // Arrange + mockPerpsController.init.mockResolvedValue(); + mockPerpsController.disconnect.mockResolvedValue(); + await PerpsConnectionManager.connect(); + expect(mockStreamManagerInstance.prices.prewarm).toHaveBeenCalledTimes(1); + + // Act + await ( + PerpsConnectionManager as unknown as { + reconnectWithNewContext: () => Promise; + } + ).reconnectWithNewContext(); + + // Assert + expect(mockStreamManagerInstance.prices.prewarm).toHaveBeenCalledTimes(2); + }); + }); + + describe('foreground reconnection — single reconnection flow', () => { + it('PerpsConnectionManager has no AppState listener — only the hook triggers foreground reconnect', () => { + // This test documents the fix for the race condition introduced by PR #26780. + // Previously, PerpsConnectionManager registered its own AppState listener in + // setupStateMonitoring(), which competed with usePerpsConnectionLifecycle hook. + // + // Both fired simultaneously on foreground: + // hook → connect() + // manager → reconnectWithNewContext({ force: true }) + // + // The force path cancelled the hook's in-flight connect() and cleaned up + // prewarm subscriptions mid-flight, leaving positions/prices without data. + // + // Fix: removed the manager-level AppState listener entirely. + // The hook is the sole owner of foreground recovery. + + const m = PerpsConnectionManager as unknown as Record; + + // appStateSubscription field must not exist on the manager + expect(m.appStateSubscription).toBeUndefined(); + + // handleAppStateChange method must not exist on the manager + expect(typeof m.handleAppStateChange).not.toBe('function'); + }); + + it('connect() on foreground completes without interference', async () => { + mockPerpsController.init.mockResolvedValue(); + + await PerpsConnectionManager.connect(); + + const state = PerpsConnectionManager.getConnectionState(); + expect(state.isConnected).toBe(true); + expect(state.isConnecting).toBe(false); + expect(state.error).toBeNull(); + // Prewarm subscriptions fired exactly once + expect(mockStreamManagerInstance.prices.prewarm).toHaveBeenCalledTimes(1); + expect(mockStreamManagerInstance.positions.prewarm).toHaveBeenCalledTimes( + 1, + ); + }); + }); + describe('waitForConnection', () => { it('awaits resolving initPromise', async () => { // Arrange — set initPromise to a resolved promise @@ -906,6 +992,161 @@ describe('PerpsConnectionManager', () => { }); }); + describe('performActualDisconnection — grace period expiry', () => { + beforeEach(async () => { + mockPerpsController.init.mockResolvedValue(); + mockPerpsController.disconnect.mockResolvedValue(); + await PerpsConnectionManager.connect(); + // Clear cache mock calls from connect/prewarm so we can assert specifically + Object.values(mockStreamManagerInstance).forEach(({ clearCache }) => + clearCache.mockClear(), + ); + }); + + it('clears all stream channel caches when grace period fires', async () => { + // Arrange + const m = PerpsConnectionManager as unknown as { + isConnected: boolean; + isInitialized: boolean; + connectionRefCount: number; + isPreloading: boolean; + performActualDisconnection: () => Promise; + }; + m.connectionRefCount = 0; + + // Act + await m.performActualDisconnection(); + + // Assert + expect(mockStreamManagerInstance.positions.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.orders.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.account.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.prices.clearCache).toHaveBeenCalled(); + expect( + mockStreamManagerInstance.marketData.clearCache, + ).toHaveBeenCalled(); + expect(mockStreamManagerInstance.oiCaps.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.fills.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.topOfBook.clearCache).toHaveBeenCalled(); + expect(mockStreamManagerInstance.candles.clearCache).toHaveBeenCalled(); + }); + + it('resets isPreloading flag so next connect can prewarm', async () => { + // Arrange + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + isPreloading: boolean; + performActualDisconnection: () => Promise; + }; + m.connectionRefCount = 0; + m.isPreloading = true; + + // Act + await m.performActualDisconnection(); + + // Assert + expect(m.isPreloading).toBe(false); + }); + + it('resets connection state flags after disconnecting', async () => { + // Arrange + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + performActualDisconnection: () => Promise; + }; + m.connectionRefCount = 0; + + // Act + await m.performActualDisconnection(); + + // Assert + const state = PerpsConnectionManager.getConnectionState(); + expect(state.isConnected).toBe(false); + expect(state.isInitialized).toBe(false); + expect(state.isConnecting).toBe(false); + }); + + it('skips disconnection when reference count is still positive', async () => { + // Arrange — refCount > 0 means another consumer reconnected during grace period + const m = PerpsConnectionManager as unknown as { + connectionRefCount: number; + performActualDisconnection: () => Promise; + }; + m.connectionRefCount = 1; + + // Act + await m.performActualDisconnection(); + + // Assert — controller not called, caches not cleared + expect(mockPerpsController.disconnect).not.toHaveBeenCalled(); + expect( + mockStreamManagerInstance.positions.clearCache, + ).not.toHaveBeenCalled(); + }); + }); + + describe('NetInfo isInternetReachable null handling', () => { + type NetInfoCallback = (state: { + isInternetReachable: boolean | null; + isConnected: boolean | null; + }) => void; + + const mockAddEventListener = mockNetInfoAddEventListener as jest.Mock; + + let capturedCallback: NetInfoCallback | null = null; + + beforeEach(async () => { + // Set up to capture the listener, then connect so it registers + mockAddEventListener.mockImplementation((cb: NetInfoCallback) => { + capturedCallback = cb; + return jest.fn(); + }); + mockPerpsController.init.mockResolvedValue(); + await PerpsConnectionManager.connect(); + }); + + it('treats isInternetReachable null as online when isConnected is true', () => { + // Arrange — start disconnected so wasOffline is not set by online path + const m = PerpsConnectionManager as unknown as { + wasOffline: boolean; + isConnected: boolean; + }; + m.isConnected = false; // not connected — online path won't clear wasOffline + m.wasOffline = false; + + // Act — null isInternetReachable falls back to isConnected=true → isOnline=true + // Since isOnline=true and !wasOffline, the "set wasOffline=true" branch is skipped + capturedCallback?.({ isInternetReachable: null, isConnected: true }); + + // Assert — wasOffline stays false (not offline path, and not connected to clear it) + expect(m.wasOffline).toBe(false); + }); + + it('treats isInternetReachable null as offline when isConnected is false', () => { + // Arrange + const m = PerpsConnectionManager as unknown as { wasOffline: boolean }; + m.wasOffline = false; + + // Act — null isInternetReachable falls back to isConnected=false → offline + capturedCallback?.({ isInternetReachable: null, isConnected: false }); + + // Assert + expect(m.wasOffline).toBe(true); + }); + + it('treats isInternetReachable false as offline', () => { + // Arrange + const m = PerpsConnectionManager as unknown as { wasOffline: boolean }; + m.wasOffline = false; + + // Act + capturedCallback?.({ isInternetReachable: false, isConnected: true }); + + // Assert + expect(m.wasOffline).toBe(true); + }); + }); + describe('getActiveProviderName', () => { it('returns activeProvider from PerpsController state', () => { // Arrange diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index 18ac0acdcb82..dbb95df5884d 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -1,4 +1,3 @@ -import { AppState, type AppStateStatus } from 'react-native'; import { addEventListener as netInfoAddEventListener, type NetInfoState, @@ -52,6 +51,7 @@ class PerpsConnectionManagerClass { private initPromise: Promise | null = null; private disconnectPromise: Promise | null = null; private hasPreloaded = false; + private isPreloading = false; private prewarmCleanups: (() => void)[] = []; private unsubscribeFromStore: (() => void) | null = null; private previousAddress: string | undefined; @@ -62,9 +62,6 @@ class PerpsConnectionManagerClass { private isInGracePeriod = false; private pendingReconnectPromise: Promise | null = null; private connectionTimeoutRef: ReturnType | null = null; - private appStateSubscription: ReturnType< - typeof AppState.addEventListener - > | null = null; private netInfoUnsubscribe: (() => void) | null = null; private wasOffline = false; private networkRestoreRetryTimer: ReturnType | null = null; @@ -190,32 +187,12 @@ class PerpsConnectionManagerClass { this.previousHip3Version = currentHip3Version; }); - // Listen for app state changes to reconnect after background - if (!this.appStateSubscription) { - this.appStateSubscription = AppState.addEventListener( - 'change', - (nextAppState: AppStateStatus) => { - this.handleAppStateChange(nextAppState).catch((error) => { - Logger.error( - ensureError(error, 'PerpsConnectionManager.appStateListener'), - { - tags: { feature: PERPS_CONSTANTS.FeatureName }, - context: { - name: 'PerpsConnectionManager.appStateListener', - data: { message: 'Error handling app state change' }, - }, - }, - ); - }); - }, - ); - } - // Listen for connectivity changes (WiFi off/on, airplane mode, etc.) if (!this.netInfoUnsubscribe) { this.netInfoUnsubscribe = netInfoAddEventListener( (netState: NetInfoState) => { - const isOnline = netState.isInternetReachable === true; + const isOnline = + netState.isInternetReachable ?? netState.isConnected ?? false; if (isOnline && this.wasOffline) { DevLogger.log( @@ -238,8 +215,7 @@ class PerpsConnectionManagerClass { /** * Validate the WebSocket connection and force-reconnect if it is stale. - * Shared by both AppState (background→foreground) and NetInfo (offline→online) - * recovery paths. + * Used by the NetInfo (offline→online) recovery path. * * @param context - Caller identifier for error reporting * @param skipPing - Skip ping and force reconnect after known network loss @@ -335,37 +311,10 @@ class PerpsConnectionManagerClass { this.networkRestoreRetryCount = 0; } - /** - * Handle app state transitions to recover from stale WebSocket connections. - * When the app returns from background, the OS may have silently killed the - * WebSocket. A health-check ping detects this and triggers reconnection. - */ - private async handleAppStateChange( - nextAppState: AppStateStatus, - ): Promise { - if (nextAppState !== 'active') { - return; - } - - // Cancel any pending grace period — user is back - if (this.isInGracePeriod) { - DevLogger.log( - 'PerpsConnectionManager: App resumed - cancelling grace period', - ); - this.cancelGracePeriod(); - } - - await this.validateAndReconnect('appResume'); - } - /** * Clean up state monitoring */ private cleanupStateMonitoring(): void { - if (this.appStateSubscription) { - this.appStateSubscription.remove(); - this.appStateSubscription = null; - } if (this.netInfoUnsubscribe) { this.netInfoUnsubscribe(); this.netInfoUnsubscribe = null; @@ -519,11 +468,25 @@ class PerpsConnectionManagerClass { // Clean up preloaded subscriptions this.cleanupPreloadedSubscriptions(); + // Clear all stream caches so subscribers reset to loading state + // and hasReceivedFirstUpdate is reset for clean reconnection + const streamManager = getStreamManagerInstance(); + streamManager.positions.clearCache(); + streamManager.orders.clearCache(); + streamManager.account.clearCache(); + streamManager.prices.clearCache(); + streamManager.marketData.clearCache(); + streamManager.oiCaps.clearCache(); + streamManager.fills.clearCache(); + streamManager.topOfBook.clearCache(); + streamManager.candles.clearCache(); + // Reset state before disconnecting to prevent race conditions this.isConnected = false; this.isInitialized = false; this.isConnecting = false; this.hasPreloaded = false; // Reset pre-load flag on disconnect + this.isPreloading = false; // Reset in-flight preload guard on disconnect this.clearError(); // Clear any errors on disconnect await Engine.context.PerpsController.disconnect(); @@ -911,6 +874,7 @@ class PerpsConnectionManagerClass { this.isConnected = false; this.isInitialized = false; this.hasPreloaded = false; + this.isPreloading = false; // Clear previous errors when starting reconnection attempt this.clearError(); @@ -1078,12 +1042,15 @@ class PerpsConnectionManagerClass { * Also sets up balance update subscriptions for portfolio integration */ private async preloadSubscriptions(): Promise { - // Only pre-load once per session - if (this.hasPreloaded) { - DevLogger.log('PerpsConnectionManager: Already pre-loaded, skipping'); + // Only pre-load once per session, and guard against concurrent calls + if (this.hasPreloaded || this.isPreloading) { + DevLogger.log( + 'PerpsConnectionManager: Already pre-loaded or preloading, skipping', + ); return; } + this.isPreloading = true; try { DevLogger.log( 'PerpsConnectionManager: Pre-loading WebSocket subscriptions via StreamManager', @@ -1137,6 +1104,8 @@ class PerpsConnectionManagerClass { }, ); // Non-critical error - components will still work with on-demand subscriptions + } finally { + this.isPreloading = false; } } diff --git a/app/components/Views/Homepage/Homepage.test.tsx b/app/components/Views/Homepage/Homepage.test.tsx index 3450f18ef7f2..d1b945e47c3f 100644 --- a/app/components/Views/Homepage/Homepage.test.tsx +++ b/app/components/Views/Homepage/Homepage.test.tsx @@ -13,9 +13,24 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ navigate: jest.fn(), }), + useFocusEffect: (callback: () => void) => { + const React = jest.requireActual('react'); + React.useEffect(callback, [callback]); + }, }; }); +const mockDetectNfts = jest.fn().mockResolvedValue(undefined); +const mockAbortDetection = jest.fn(); + +jest.mock('../../hooks/useNftDetection', () => ({ + useNftDetection: () => ({ + detectNfts: mockDetectNfts, + abortDetection: mockAbortDetection, + chainIdsToDetectNftsFor: [], + }), +})); + // Mock feature flags - enable all sections jest.mock('../../UI/Perps', () => ({ selectPerpsEnabledFlag: jest.fn(() => true), diff --git a/app/components/Views/Homepage/Sections/NFTs/NFTsSection.test.tsx b/app/components/Views/Homepage/Sections/NFTs/NFTsSection.test.tsx index b61f2e122fd7..1690687e3782 100644 --- a/app/components/Views/Homepage/Sections/NFTs/NFTsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/NFTs/NFTsSection.test.tsx @@ -8,6 +8,8 @@ import { SectionRefreshHandle } from '../../types'; const mockNavigate = jest.fn(); const mockOnRefresh = jest.fn().mockResolvedValue(undefined); +const mockDetectNfts = jest.fn().mockResolvedValue(undefined); +const mockAbortDetection = jest.fn(); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -16,6 +18,10 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ navigate: mockNavigate, }), + useFocusEffect: (callback: () => void) => { + const React = jest.requireActual('react'); + React.useEffect(callback, [callback]); + }, }; }); @@ -23,6 +29,14 @@ jest.mock('../../../../../reducers/collectibles', () => ({ isNftFetchingProgressSelector: jest.fn(() => false), })); +jest.mock('../../../../hooks/useNftDetection', () => ({ + useNftDetection: () => ({ + detectNfts: mockDetectNfts, + abortDetection: mockAbortDetection, + chainIdsToDetectNftsFor: [], + }), +})); + jest.mock('../../../../UI/NftGrid/useNftRefresh', () => ({ useNftRefresh: () => ({ refreshing: false, @@ -139,6 +153,30 @@ describe('NFTsSection', () => { expect(screen.queryByText('NFT 7')).not.toBeOnTheScreen(); }); + it('triggers NFT detection on focus', () => { + renderWithProvider(); + + expect(mockDetectNfts).toHaveBeenCalledTimes(1); + }); + + it('calls abortDetection on unmount', () => { + const { unmount } = renderWithProvider(); + + unmount(); + + expect(mockAbortDetection).toHaveBeenCalledTimes(1); + }); + + it('renders without error when detectNfts rejects', async () => { + mockDetectNfts.mockRejectedValueOnce(new Error('Aborted')); + + renderWithProvider(); + + await act(async () => undefined); + + expect(screen.getByText('NFTs')).toBeOnTheScreen(); + }); + it('exposes refresh function via ref that calls useNftRefresh.onRefresh', async () => { const ref = createRef(); diff --git a/app/components/Views/Homepage/Sections/NFTs/NFTsSection.tsx b/app/components/Views/Homepage/Sections/NFTs/NFTsSection.tsx index 201ce6978043..b49c0e7dc0ec 100644 --- a/app/components/Views/Homepage/Sections/NFTs/NFTsSection.tsx +++ b/app/components/Views/Homepage/Sections/NFTs/NFTsSection.tsx @@ -3,11 +3,12 @@ import React, { useCallback, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useTheme } from '../../../../../util/theme'; @@ -19,6 +20,7 @@ import { useOwnedNfts } from './hooks'; import NftGridItem from '../../../../UI/NftGrid/NftGridItem'; import { useNftRefresh } from '../../../../UI/NftGrid/useNftRefresh'; import { CollectiblesEmptyState } from '../../../../UI/CollectiblesEmptyState/CollectiblesEmptyState'; +import { useNftDetection } from '../../../../hooks/useNftDetection'; import { SectionRefreshHandle } from '../../types'; import { strings } from '../../../../../../locales/i18n'; import { isNftFetchingProgressSelector } from '../../../../../reducers/collectibles'; @@ -59,6 +61,31 @@ const NFTsSection = forwardRef((_, ref) => { const hasNfts = ownedNfts.length > 0; const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); const { onRefresh } = useNftRefresh(); + const { detectNfts, abortDetection } = useNftDetection(); + const hasLoadedOnceRef = useRef(false); + const isSilentDetectionRef = useRef(false); + + useFocusEffect( + useCallback(() => { + isSilentDetectionRef.current = hasLoadedOnceRef.current; + + detectNfts() + .catch(() => { + // AbortError is expected when detection is cancelled on blur + }) + .finally(() => { + hasLoadedOnceRef.current = true; + isSilentDetectionRef.current = false; + }); + + return () => { + abortDetection(); + isSilentDetectionRef.current = false; + }; + }, [detectNfts, abortDetection]), + ); + + const showSkeleton = isNftFetchingProgress && !isSilentDetectionRef.current; const title = strings('homepage.sections.nfts'); @@ -128,7 +155,7 @@ const NFTsSection = forwardRef((_, ref) => { ))} - ) : isNftFetchingProgress ? ( + ) : showSkeleton ? ( diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx index 375d14ffff2a..ad7f94e5fd45 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.test.tsx @@ -765,6 +765,34 @@ describe('NetworkDetailsView', () => { expect(ops.removeNetwork).toHaveBeenCalledWith('0x2a'); }); + + it('does not show trash icon when editing non-deletable network (e.g. Ethereum mainnet)', () => { + const mainnetEditForm = createMockFormHook({ + addMode: false, + nickname: 'Ethereum', + chainId: '0x1', + ticker: 'ETH', + rpcUrl: 'https://mainnet.infura.io/v3/key', + rpcName: 'Infura', + rpcUrls: [ + { + url: 'https://mainnet.infura.io/v3/key', + name: 'Infura', + type: 'infura', + }, + ], + blockExplorerUrl: 'https://etherscan.io', + blockExplorerUrls: ['https://etherscan.io'], + editable: true, + }); + mockFormHook.mockReturnValue(mainnetEditForm); + + const { getByTestId } = render(); + + const container = getByTestId(NetworkDetailsViewSelectorsIDs.CONTAINER); + const trashIcons = container.findAllByProps({ name: 'Trash' }); + expect(trashIcons).toHaveLength(0); + }); }); describe('RPC modal interactions', () => { diff --git a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx index ba1f67445f49..7532ff6b7f3b 100644 --- a/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx +++ b/app/components/Views/NetworksManagement/NetworkDetailsView/NetworkDetailsView.tsx @@ -24,7 +24,10 @@ import { CaipChainId } from '@metamask/utils'; import { strings } from '../../../../../locales/i18n'; import { useTheme } from '../../../../util/theme'; import { useStyles } from '../../../../component-library/hooks/useStyles'; -import { getNetworkImageSource } from '../../../../util/networks'; +import { + canDeleteNetwork, + getNetworkImageSource, +} from '../../../../util/networks'; import { useNetworkEnablement } from '../../../hooks/useNetworkEnablement/useNetworkEnablement'; import { selectIsRpcFailoverEnabled } from '../../../../selectors/featureFlagController/walletFramework'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; @@ -186,7 +189,8 @@ const NetworkDetailsView = () => { diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts index 4af8eb287dc2..f2504d63c254 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.test.ts @@ -358,6 +358,29 @@ describe('useInsufficientPayTokenBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); + + it('uses the standard message (with network switch hint) for non-post-quote flows', () => { + useTokenWithBalanceMock.mockReturnValue({ + ...NATIVE_TOKEN_MOCK, + balanceRaw: '99', + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([ + { + key: AlertKeys.InsufficientPayTokenNative, + field: RowAlertKey.Amount, + isBlocking: true, + title: strings('alert_system.insufficient_pay_token_balance.message'), + message: strings( + 'alert_system.insufficient_pay_token_native.message', + { ticker: 'ETH' }, + ), + severity: Severity.Danger, + }, + ]); + }); }); describe('for post-quote (withdrawal) flows', () => { @@ -461,7 +484,7 @@ describe('useInsufficientPayTokenBalanceAlert', () => { isBlocking: true, title: strings('alert_system.insufficient_pay_token_balance.message'), message: strings( - 'alert_system.insufficient_pay_token_native.message', + 'alert_system.insufficient_pay_token_native_post_quote.message', { ticker: 'POL' }, ), severity: Severity.Danger, @@ -491,7 +514,7 @@ describe('useInsufficientPayTokenBalanceAlert', () => { isBlocking: true, title: strings('alert_system.insufficient_pay_token_balance.message'), message: strings( - 'alert_system.insufficient_pay_token_native.message', + 'alert_system.insufficient_pay_token_native_post_quote.message', { ticker: 'POL' }, ), severity: Severity.Danger, @@ -528,7 +551,7 @@ describe('useInsufficientPayTokenBalanceAlert', () => { isBlocking: true, title: strings('alert_system.insufficient_pay_token_balance.message'), message: strings( - 'alert_system.insufficient_pay_token_native.message', + 'alert_system.insufficient_pay_token_native_post_quote.message', { ticker: 'POL' }, ), severity: Severity.Danger, diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts index fc9a46635f3a..f53788c2af22 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert.ts @@ -179,7 +179,9 @@ export function useInsufficientPayTokenBalanceAlert({ key: AlertKeys.InsufficientPayTokenNative, title: strings('alert_system.insufficient_pay_token_balance.message'), message: strings( - 'alert_system.insufficient_pay_token_native.message', + isPostQuote + ? 'alert_system.insufficient_pay_token_native_post_quote.message' + : 'alert_system.insufficient_pay_token_native.message', { ticker }, ), }, @@ -191,6 +193,7 @@ export function useInsufficientPayTokenBalanceAlert({ isInsufficientForInput, isInsufficientForFees, isInsufficientForSourceNetwork, + isPostQuote, ticker, ]); } diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts index bfca61f71ead..9b3f47537c82 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.test.ts @@ -165,4 +165,24 @@ describe('getGasMetricsProperties', () => { expect(result.properties.gas_insufficient_native_asset).toBe(false); }); + + describe('MM Pay transactions', () => { + it('derives gas_paid_with from metamaskPay.tokenAddress instead of selectedGasFeeToken', () => { + const request = createMockRequest({ + selectedGasFeeToken: '0xignored', + metamaskPay: { + chainId: '0x1', + tokenAddress: '0xusdc', + }, + gasFeeTokens: [ + { symbol: 'USDC', tokenAddress: '0xusdc' }, + { symbol: 'IGNORED', tokenAddress: '0xignored' }, + ] as unknown as TransactionMeta['gasFeeTokens'], + }); + + const result = getGasMetricsProperties(request); + + expect(result.properties.gas_paid_with).toBe('USDC'); + }); + }); }); diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts index 3cc816d29451..a03cce2f5796 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/gas.ts @@ -40,19 +40,25 @@ export function getGasMetricsProperties({ (token) => token.symbol, ); + const { metamaskPay } = transactionMeta; + const gasFeeTokenAddress = metamaskPay?.tokenAddress ?? selectedGasFeeToken; + const gasFeeChainId = metamaskPay?.chainId ?? chainId; + let gas_paid_with = gasFeeTokens?.find( (token) => - token.tokenAddress.toLowerCase() === selectedGasFeeToken?.toLowerCase(), + token.tokenAddress.toLowerCase() === gasFeeTokenAddress?.toLowerCase(), )?.symbol; - if (selectedGasFeeToken?.toLowerCase() === getNativeTokenAddress(chainId)) { + if ( + gasFeeTokenAddress?.toLowerCase() === getNativeTokenAddress(gasFeeChainId) + ) { gas_paid_with = 'pre-funded_ETH'; } const state = getState(); const gas_insufficient_native_asset = getNativeBalance( state, - chainId, + gasFeeChainId, from, ).lt(getMaxGasCost(transactionMeta)); diff --git a/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts b/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts index 73ac0aaff65d..660e7bdaf638 100644 --- a/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts +++ b/app/core/SDKConnectV2/adapters/host-application-adapter.test.ts @@ -136,6 +136,74 @@ describe('HostApplicationAdapter', () => { }); }); + describe('showInternalError', () => { + it('dispatches an error notification with internal error message', () => { + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + + adapter.showInternalError(); + + expect(showSimpleNotification).toHaveBeenCalledTimes(1); + expect(showSimpleNotification).toHaveBeenCalledWith({ + id: '1234567890', + autodismiss: 5000, + title: 'sdk_connect_v2.show_internal_error.title', + description: 'sdk_connect_v2.show_internal_error.description', + status: 'error', + }); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + it('dispatches an internal error notification with connection info', () => { + adapter.showInternalError( + createMockConnectionInfo('session-123', 'Test DApp'), + ); + + expect(showSimpleNotification).toHaveBeenCalledTimes(1); + expect(showSimpleNotification).toHaveBeenCalledWith({ + id: 'session-123', + autodismiss: 5000, + title: 'sdk_connect_v2.show_internal_error.title', + description: 'sdk_connect_v2.show_internal_error.description', + status: 'error', + }); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + }); + + describe('showMethodError', () => { + it('dispatches an error notification with method error message', () => { + jest.spyOn(Date, 'now').mockReturnValue(1234567890); + + adapter.showMethodError(); + + expect(showSimpleNotification).toHaveBeenCalledTimes(1); + expect(showSimpleNotification).toHaveBeenCalledWith({ + id: '1234567890', + autodismiss: 5000, + title: 'sdk_connect_v2.show_method_error.title', + description: 'sdk_connect_v2.show_method_error.description', + status: 'error', + }); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + it('dispatches a method error notification with connection info', () => { + adapter.showMethodError( + createMockConnectionInfo('session-123', 'Test DApp'), + ); + + expect(showSimpleNotification).toHaveBeenCalledTimes(1); + expect(showSimpleNotification).toHaveBeenCalledWith({ + id: 'session-123', + autodismiss: 5000, + title: 'sdk_connect_v2.show_method_error.title', + description: 'sdk_connect_v2.show_method_error.description', + status: 'error', + }); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + }); + describe('showConfirmationRejectionError', () => { it('dispatches a rejection error notification with connection info', () => { adapter.showConfirmationRejectionError( diff --git a/app/core/SDKConnectV2/adapters/host-application-adapter.ts b/app/core/SDKConnectV2/adapters/host-application-adapter.ts index 61910b89550e..33044969465d 100644 --- a/app/core/SDKConnectV2/adapters/host-application-adapter.ts +++ b/app/core/SDKConnectV2/adapters/host-application-adapter.ts @@ -45,6 +45,30 @@ export class HostApplicationAdapter implements IHostApplicationAdapter { ); } + showInternalError(conninfo?: ConnectionInfo): void { + store.dispatch( + showSimpleNotification({ + id: conninfo?.id || Date.now().toString(), + autodismiss: 5000, + title: strings('sdk_connect_v2.show_internal_error.title'), + description: strings('sdk_connect_v2.show_internal_error.description'), + status: 'error', + }), + ); + } + + showMethodError(conninfo?: ConnectionInfo): void { + store.dispatch( + showSimpleNotification({ + id: conninfo?.id || Date.now().toString(), + autodismiss: 5000, + title: strings('sdk_connect_v2.show_method_error.title'), + description: strings('sdk_connect_v2.show_method_error.description'), + status: 'error', + }), + ); + } + showConfirmationRejectionError(conninfo?: ConnectionInfo): void { store.dispatch( showSimpleNotification({ diff --git a/app/core/SDKConnectV2/services/connection.test.ts b/app/core/SDKConnectV2/services/connection.test.ts index 051e75f8ff1b..f7f54d0b821e 100644 --- a/app/core/SDKConnectV2/services/connection.test.ts +++ b/app/core/SDKConnectV2/services/connection.test.ts @@ -76,6 +76,8 @@ const mockHostApp: jest.Mocked = { showConnectionLoading: jest.fn(), hideConnectionLoading: jest.fn(), showConnectionError: jest.fn(), + showInternalError: jest.fn(), + showMethodError: jest.fn(), showNotFoundError: jest.fn(), showConfirmationRejectionError: jest.fn(), showReturnToApp: jest.fn(), @@ -427,7 +429,7 @@ describe('Connection', () => { ); }); - it('shows error toast when bridge response includes an error', async () => { + it('shows internal error toast for server-range error codes (-32000 to -32099)', async () => { await Connection.create( mockConnectionInfo, mockKeyManager, @@ -441,29 +443,94 @@ describe('Connection', () => { jsonrpc: '2.0', error: { code: -32000, - message: 'User rejected the request', + message: 'Server error', }, }, }; - // Simulate the RPCBridgeAdapter emitting an error response onBridgeResponseCallback(errorResponsePayload); - // Should show error toast, not success toast - expect(mockHostApp.showConnectionError).toHaveBeenCalledTimes(1); - expect(mockHostApp.showConnectionError).toHaveBeenCalledWith( + expect(mockHostApp.showInternalError).toHaveBeenCalledTimes(1); + expect(mockHostApp.showInternalError).toHaveBeenCalledWith( mockConnectionInfo, ); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + expect(mockHostApp.showConfirmationRejectionError).not.toHaveBeenCalled(); expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); - // And still send the error response to the client expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledWith( errorResponsePayload, ); }); - it('shows confirmation rejection error toast when bridge response includes user rejected request error', async () => { + it('shows internal error toast for JSON-RPC internal error code (-32603)', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const errorResponsePayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal error', + }, + }, + }; + + onBridgeResponseCallback(errorResponsePayload); + + expect(mockHostApp.showInternalError).toHaveBeenCalledTimes(1); + expect(mockHostApp.showInternalError).toHaveBeenCalledWith( + mockConnectionInfo, + ); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); + + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); + }); + + it('shows method error toast for non-rejection, non-internal error codes', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const errorResponsePayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: 53, + reason: 'Invalid URL', + }, + }, + }; + + onBridgeResponseCallback(errorResponsePayload); + + expect(mockHostApp.showMethodError).toHaveBeenCalledTimes(1); + expect(mockHostApp.showMethodError).toHaveBeenCalledWith( + mockConnectionInfo, + ); + expect(mockHostApp.showInternalError).not.toHaveBeenCalled(); + expect(mockHostApp.showConfirmationRejectionError).not.toHaveBeenCalled(); + expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); + + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledWith( + errorResponsePayload, + ); + }); + + it('shows confirmation rejection error toast for EVM user rejected request (4001)', async () => { await Connection.create( mockConnectionInfo, mockKeyManager, @@ -482,26 +549,158 @@ describe('Connection', () => { }, }; - // Simulate the RPCBridgeAdapter emitting a user rejected error response onBridgeResponseCallback(userRejectedErrorResponsePayload); - // Should show confirmation rejection error toast, not generic error toast expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledTimes( 1, ); expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledWith( mockConnectionInfo, ); - expect(mockHostApp.showConnectionError).not.toHaveBeenCalled(); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + expect(mockHostApp.showInternalError).not.toHaveBeenCalled(); expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); - // And still send the error response to the client expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledWith( userRejectedErrorResponsePayload, ); }); + it('shows confirmation rejection error toast for Solana user rejection (5000)', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const solanaRejectionPayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: 5000, + message: 'User rejected the request', + }, + }, + }; + + onBridgeResponseCallback(solanaRejectionPayload); + + expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledTimes( + 1, + ); + expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledWith( + mockConnectionInfo, + ); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + expect(mockHostApp.showInternalError).not.toHaveBeenCalled(); + expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); + + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); + }); + + it('shows rejection toast when SnapKeyring strips the code but message contains rejection text', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const snapKeyringRejectionPayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Request rejected by user or snap.', + }, + }, + }; + + onBridgeResponseCallback(snapKeyringRejectionPayload); + + expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledTimes( + 1, + ); + expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledWith( + mockConnectionInfo, + ); + expect(mockHostApp.showInternalError).not.toHaveBeenCalled(); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + expect(mockHostApp.showReturnToApp).not.toHaveBeenCalled(); + + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); + }); + + it('shows rejection toast when error message contains "User rejected" regardless of code', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const wrappedRejectionPayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: -32603, + message: 'User rejected the request', + }, + }, + }; + + onBridgeResponseCallback(wrappedRejectionPayload); + + expect(mockHostApp.showConfirmationRejectionError).toHaveBeenCalledTimes( + 1, + ); + expect(mockHostApp.showInternalError).not.toHaveBeenCalled(); + expect(mockHostApp.showMethodError).not.toHaveBeenCalled(); + + expect(mockWalletClientInstance.sendResponse).toHaveBeenCalledTimes(1); + }); + + it('logs error payload at warn level when error toast is shown', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + const errorResponsePayload = { + data: { + id: 1, + jsonrpc: '2.0', + error: { + code: 53, + message: 'Invalid URL', + }, + }, + }; + + onBridgeResponseCallback(errorResponsePayload); + + expect(warnSpy).toHaveBeenCalledWith( + '[SDKConnectV2]', + 'RPC error response', + { + connectionId: mockConnectionInfo.id, + code: 53, + message: 'Invalid URL', + }, + ); + + warnSpy.mockRestore(); + }); + it('shows success toast for successful response with result', async () => { await Connection.create( mockConnectionInfo, diff --git a/app/core/SDKConnectV2/services/connection.ts b/app/core/SDKConnectV2/services/connection.ts index 8d99cdf9a38c..66f1f4bf151d 100644 --- a/app/core/SDKConnectV2/services/connection.ts +++ b/app/core/SDKConnectV2/services/connection.ts @@ -17,6 +17,40 @@ import { errorCodes, providerErrors } from '@metamask/rpc-errors'; import Engine from '../../Engine'; import NavigationService from '../../NavigationService'; +/** + * Known user-rejection error codes across ecosystems. + * - 4001: EVM standard (EIP-1193) + * - 5000: Solana wallet standard (user rejected) + */ +const REJECTION_CODES: ReadonlySet = new Set([ + errorCodes.provider.userRejectedRequest, // 4001 + 5000, // Solana wallet-standard user rejection +]); + +/** + * Message patterns that indicate a user rejection even when the original + * error code has been lost. The SnapKeyring strips the 4001 code from + * approval rejections and re-throws a plain Error, which serializeError + * then wraps with the fallback code -32603. We match on the message to + * recover the user-rejection intent. + */ +const REJECTION_MESSAGE_PATTERNS: readonly string[] = [ + 'request rejected by user or snap', + 'user rejected', +]; + +const isRejectionMessage = (message: unknown): boolean => { + if (typeof message !== 'string') return false; + const lower = message.toLowerCase(); + return REJECTION_MESSAGE_PATTERNS.some((pattern) => lower.includes(pattern)); +}; + +/** + * Standard JSON-RPC internal error range: -32603, and server errors -32000 to -32099. + */ +const isInternalError = (code: number): boolean => + code === errorCodes.rpc.internal || (code >= -32099 && code <= -32000); + /** * Connection is a live, runtime representation of a dApp connection. */ @@ -100,12 +134,23 @@ export class Connection { 'error' in responseData && responseData.error !== undefined; if (isError) { - if ( - responseData.error.code === errorCodes.provider.userRejectedRequest - ) { + const errCode = responseData.error.code as number; + const errMessage = + (responseData.error as Record).message ?? + (responseData.error as Record).reason; + + logger.warn('RPC error response', { + connectionId: this.id, + code: errCode, + message: errMessage, + }); + + if (REJECTION_CODES.has(errCode) || isRejectionMessage(errMessage)) { this.hostApp.showConfirmationRejectionError(this.info); + } else if (isInternalError(errCode)) { + this.hostApp.showInternalError(this.info); } else { - this.hostApp.showConnectionError(this.info); + this.hostApp.showMethodError(this.info); } } else { this.hostApp.showReturnToApp(this.info); diff --git a/app/core/SDKConnectV2/services/logger.ts b/app/core/SDKConnectV2/services/logger.ts index dde10cd678e5..31c0dc1a42f9 100644 --- a/app/core/SDKConnectV2/services/logger.ts +++ b/app/core/SDKConnectV2/services/logger.ts @@ -26,6 +26,10 @@ export default { console.debug(prettify(prefix, ...args)); } }, + warn: (...args: unknown[]) => { + // eslint-disable-next-line no-console + console.warn(prefix, ...args); + }, error: (...args: unknown[]) => { // eslint-disable-next-line no-console console.error(prefix, ...args); diff --git a/app/core/SDKConnectV2/types/host-application-adapter.ts b/app/core/SDKConnectV2/types/host-application-adapter.ts index 8686c2f3794d..444d61daa834 100644 --- a/app/core/SDKConnectV2/types/host-application-adapter.ts +++ b/app/core/SDKConnectV2/types/host-application-adapter.ts @@ -21,10 +21,23 @@ export interface IHostApplicationAdapter { hideConnectionLoading(conninfo: ConnectionInfo): void; /** - * Displays a global, non-interactive error modal. + * Displays a connection-level error toast. Use only when the MWP + * session/handshake itself fails, not for RPC method errors. */ showConnectionError(conninfo?: ConnectionInfo): void; + /** + * Displays a toast for an unexpected internal error (e.g. URL parsing + * failure, uncategorized error codes). + */ + showInternalError(conninfo?: ConnectionInfo): void; + + /** + * Displays a toast for an RPC method error that is not a user rejection + * (e.g. invalid params, method not found). + */ + showMethodError(conninfo?: ConnectionInfo): void; + /** * Displays a global, non-interactive not found modal. */ diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 379bf693d9e6..62df848cc7fc 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -343,6 +343,21 @@ export const isTestNetworkWithFaucet = (chainId) => */ export const isTestNet = (chainId) => TESTNET_CHAIN_IDS.includes(chainId); +/** + * Returns whether the network can be deleted by the user. + * Aligns with NetworkSelector: mainnet, Linea mainnet, and testnets cannot be removed. + * + * @param {string} chainId - The chain ID to check (e.g. '0x1', '0x89'). + * @returns {boolean} True if the network can be deleted, false otherwise. + */ +export const canDeleteNetwork = (chainId) => + Boolean( + chainId && + !isTestNet(chainId) && + !isMainNet(chainId) && + !isLineaMainnetChainId(chainId), + ); + export function getNetworkTypeById(id) { if (!id) { throw new Error(NetworkSwitchErrorType.missingNetworkId); diff --git a/app/util/networks/index.test.ts b/app/util/networks/index.test.ts index d79efe305dbe..60c1a04da1ba 100644 --- a/app/util/networks/index.test.ts +++ b/app/util/networks/index.test.ts @@ -19,6 +19,7 @@ import NetworkList, { isWhitelistedSymbol, isWhitelistedRpcUrl, isWhitelistedNetworkName, + canDeleteNetwork, } from '.'; import { convertNetworkId, @@ -162,6 +163,33 @@ describe('network-utils', () => { }); }); + describe('canDeleteNetwork', () => { + it('returns false for Ethereum mainnet', () => { + expect(canDeleteNetwork('0x1')).toBe(false); + }); + it('returns false for Linea mainnet', () => { + expect(canDeleteNetwork(NETWORKS_CHAIN_ID.LINEA_MAINNET)).toBe(false); + }); + it('returns false for Goerli', () => { + expect(canDeleteNetwork(NETWORKS_CHAIN_ID.GOERLI)).toBe(false); + }); + it.each(TESTNET_CHAIN_IDS)( + 'returns false for testnet chain ID %s', + (chainId) => { + expect(canDeleteNetwork(chainId)).toBe(false); + }, + ); + it('returns false for empty/falsy chainId', () => { + expect(canDeleteNetwork('')).toBe(false); + }); + it('returns true for custom mainnet (e.g. Polygon)', () => { + expect(canDeleteNetwork(NETWORKS_CHAIN_ID.POLYGON)).toBe(true); + }); + it('returns true for custom chain ID 0x2a', () => { + expect(canDeleteNetwork('0x2a')).toBe(true); + }); + }); + describe('getNetworkTypeById', () => { it.each([getAllNetworks()])( 'should get network type by Id - %s', diff --git a/bitrise.yml b/bitrise.yml index 7f79a4f7aa35..07c338117f58 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -2374,6 +2374,15 @@ workflows: - pipeline_intermediate_files: $PROJECT_LOCATION/app/build/outputs/bundle/$OUTPUT_PATH/$RENAMED_AAB_FILE:BITRISE_PLAY_STORE_ABB_PATH - deploy_path: $PROJECT_LOCATION/app/build/outputs/bundle/$OUTPUT_PATH/$RENAMED_AAB_FILE title: Bitrise Deploy AAB + - deploy-to-bitrise-io@2.2.3: + is_always_run: false + is_skippable: true + run_if: '{{not (enveq "IS_DEV_BUILD" "true")}}' + inputs: + - deploy_path: $PROJECT_LOCATION/app/build/generated/sourcemaps/react/$OUTPUT_PATH + - is_compress: true + - zip_name: Android_Sourcemaps_$OUTPUT_PATH + title: Deploy Android Sourcemaps - script@1: title: Prepare Android build outputs for caching run_if: '{{and (getenv "ANDROID_PR_BUILD_CACHE_KEY" | ne "") (getenv "SHARE_WITH_DETOX" | eq "true")}}' @@ -3050,6 +3059,14 @@ workflows: inputs: - deploy_path: ios/build/$RENAMED_ARCHIVE_FILE title: Deploy Symbols File + - deploy-to-bitrise-io@2.2.3: + is_always_run: false + is_skippable: true + run_if: '{{not (enveq "IS_SIM_BUILD" "true")}}' # Only run for physical builds + inputs: + - pipeline_intermediate_files: sourcemaps/ios/index.js.map:BITRISE_APP_STORE_SOURCEMAP_PATH + - deploy_path: sourcemaps/ios/index.js.map + title: Deploy Source Map - save-cache@1: title: Save iOS PR Build Cache run_if: '{{and (getenv "IOS_PR_BUILD_CACHE_KEY" | ne "") (getenv "SHARE_WITH_DETOX" | eq "true")}}' diff --git a/locales/languages/en.json b/locales/languages/en.json index 5b1e1b3ff446..31aae4b370b7 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -103,6 +103,10 @@ "message": "Not enough {{ticker}} to cover fees. Use a token on another network or add more {{ticker}} to continue.", "title": "Insufficient funds" }, + "insufficient_pay_token_native_post_quote": { + "message": "Not enough {{ticker}} to cover fees. Add {{ticker}} to continue.", + "title": "Insufficient funds for post quote" + }, "no_pay_token_quotes": { "message": "This payment route isn't available right now. Try changing the amount, network, or token and we'll find the best option.", "title": "No quotes" @@ -7233,6 +7237,7 @@ "order_metal_card_description": "Order your physical Metal Card now", "cashback": "Cashback", "cashback_description": "Earn 1% back on all spending", + "cashback_description_metal": "Earn 3% back on all spending", "freeze_card": "Freeze card", "unfreeze_card": "Unfreeze card", "freeze_card_description": "Pause all spending on your card", @@ -7819,6 +7824,14 @@ "show_not_found": { "title": "Connection Not Found", "description": "Please establish a new connection from the app to continue." + }, + "show_internal_error": { + "title": "Something went wrong", + "description": "An unexpected error occurred. Please try again." + }, + "show_method_error": { + "title": "Request failed", + "description": "The request could not be completed. Please try again." } }, "network_connection_banner": { diff --git a/package.json b/package.json index e1817282359e..1885e20fc9f6 100644 --- a/package.json +++ b/package.json @@ -175,9 +175,7 @@ "bn.js@npm:4.11.6": "4.12.3", "bn.js@npm:5.2.1": "5.2.3", "@metamask/bridge-controller@npm:^67.1.1": "patch:@metamask/bridge-controller@npm%3A67.2.0#~/.yarn/patches/@metamask-bridge-controller-npm-67.2.0-32d3aafe1f.patch", - "@metamask/bridge-status-controller@npm:^67.0.1": "patch:@metamask/bridge-status-controller@npm%3A67.0.1#~/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch", - "@metamask/assets-controllers@npm:^99.4.0": "patch:@metamask/assets-controllers@npm%3A100.0.3#~/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch", - "@metamask/ramps-controller": "npm:@metamask-previews/ramps-controller@10.0.0-preview-225638478" + "@metamask/bridge-status-controller@npm:^67.0.1": "patch:@metamask/bridge-status-controller@npm%3A67.0.1#~/.yarn/patches/@metamask-bridge-status-controller-npm-67.0.1-d8a41d9c02.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -203,7 +201,7 @@ "@metamask/app-metadata-controller": "^2.0.0", "@metamask/approval-controller": "^8.0.0", "@metamask/assets-controller": "^2.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A100.0.3#~/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch", + "@metamask/assets-controllers": "^100.1.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.10.0", "@metamask/bridge-controller": "^67.4.0", @@ -267,7 +265,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^2.0.0", "@metamask/profile-sync-controller": "^27.1.0", - "@metamask/ramps-controller": "^10.0.0", + "@metamask/ramps-controller": "^10.2.0", "@metamask/react-native-acm": "^1.0.1", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", @@ -285,7 +283,7 @@ "@metamask/signature-controller": "^35.0.0", "@metamask/slip44": "^4.2.0", "@metamask/smart-transactions-controller": "^22.6.0", - "@metamask/snaps-controllers": "^18.0.1", + "@metamask/snaps-controllers": "^18.0.2", "@metamask/snaps-execution-environments": "^11.0.0", "@metamask/snaps-rpc-methods": "^14.3.0", "@metamask/snaps-sdk": "^10.4.0", @@ -479,7 +477,7 @@ "redux": "^4.2.1", "redux-mock-store": "1.5.4", "redux-persist": "6.0.0", - "redux-persist-filesystem-storage": "^4.2.0", + "redux-persist-filesystem-storage": "patch:redux-persist-filesystem-storage@npm%3A4.2.0#~/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch", "redux-saga": "^1.3.0", "redux-thunk": "^2.4.2", "reselect": "^5.1.1", diff --git a/scripts/ios/bundle-js-and-sentry-upload.sh b/scripts/ios/bundle-js-and-sentry-upload.sh index f36ff96b3974..a0dcf21d633d 100755 --- a/scripts/ios/bundle-js-and-sentry-upload.sh +++ b/scripts/ios/bundle-js-and-sentry-upload.sh @@ -26,6 +26,16 @@ export SENTRY_DISABLE_AUTO_UPLOAD=${SENTRY_DISABLE_AUTO_UPLOAD:-"true"} export SENTRY_DIST=$CURRENT_PROJECT_VERSION export SENTRY_RELEASE="$PRODUCT_BUNDLE_IDENTIFIER@$MARKETING_VERSION+$SENTRY_DIST" +# Write source map to a fixed absolute path so Bitrise (and other CI) can +# deploy it AND Sentry CLI can locate it for upload. +# Using a relative path breaks Sentry: react-native-xcode.sh writes the file +# with CWD=repo_root, but sentry-cli resolves SOURCEMAP_FILE relative to its +# own CWD (ios/), causing a path mismatch. An absolute path anchored on +# $PROJECT_DIR (set by Xcode to the ios/ directory) is unambiguous for all +# sub-processes. +REPO_ROOT="$(cd "${PROJECT_DIR}/.." && pwd)" +mkdir -p "$REPO_ROOT/sourcemaps/ios" +export SOURCEMAP_FILE="${SOURCEMAP_FILE:-$REPO_ROOT/sourcemaps/ios/index.js.map}" # Generate JS bundle and upload Sentry source maps REACT_NATIVE_XCODE="../node_modules/react-native/scripts/react-native-xcode.sh" diff --git a/tests/api-mocking/MockServerE2E.ts b/tests/api-mocking/MockServerE2E.ts index 7161f4592645..6ef7082b953e 100644 --- a/tests/api-mocking/MockServerE2E.ts +++ b/tests/api-mocking/MockServerE2E.ts @@ -368,12 +368,22 @@ export default class MockServerE2E implements Resource { } } - return handleDirectFetch( - updatedUrl, - method, - request.headers, - method === 'POST' ? requestBodyText : undefined, - ); + try { + return await handleDirectFetch( + updatedUrl, + method, + request.headers, + method === 'POST' ? requestBodyText : undefined, + ); + } catch (error) { + // Client dropped the connection before we could respond (e.g. bridge + // controller AbortController fired mid-request). Return a benign + // response so mockttp doesn't surface an unhandled rejection. + if (error instanceof Error && error.message === 'Aborted') { + return { statusCode: 499, body: '' }; + } + throw error; + } } finally { this._activeRequests--; } @@ -430,12 +440,22 @@ export default class MockServerE2E implements Resource { } } - return handleDirectFetch( - translatedUrl, - request.method, - request.headers, - await request.body.getText(), - ); + try { + return await handleDirectFetch( + translatedUrl, + request.method, + request.headers, + await request.body.getText(), + ); + } catch (error) { + // Client dropped the connection before we could respond (e.g. bridge + // controller AbortController fired mid-request). Return a benign + // response so mockttp doesn't surface an unhandled rejection. + if (error instanceof Error && error.message === 'Aborted') { + return { statusCode: 499, body: '' }; + } + throw error; + } } finally { this._activeRequests--; } diff --git a/tests/helpers/swap/constants.ts b/tests/helpers/swap/constants.ts index 1569d25543f4..c07ab445f335 100644 --- a/tests/helpers/swap/constants.ts +++ b/tests/helpers/swap/constants.ts @@ -1,3 +1,12 @@ +/** + * Wraps quote fixture objects as an SSE-formatted response string. + * Required because bridge-controller uses fetchServerEvents (SSE parser) + * which expects lines prefixed with "data: ". Raw JSON returns zero quotes. + */ +export function toSSEResponse(quotes: object[]): string { + return quotes.map((q) => `data: ${JSON.stringify(q)}\n\n`).join(''); +} + export const localNodeOptions = { hardfork: 'london', mnemonic: diff --git a/tests/helpers/swap/swap-mocks.ts b/tests/helpers/swap/swap-mocks.ts index d544c974c107..a37d16bae911 100644 --- a/tests/helpers/swap/swap-mocks.ts +++ b/tests/helpers/swap/swap-mocks.ts @@ -18,6 +18,7 @@ import { GET_TOKENS_API_USDC_RESPONSE, GET_TOKENS_API_USDT_RESPONSE, GET_QUOTE_USDC_GOOGLON_RESPONSE, + toSSEResponse, } from './constants'; const USDC_MAINNET = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; @@ -73,66 +74,164 @@ export const testSpecificMock: TestSpecificMock = async ( ) => { await setupSpotPricesMock(mockServer); - // Mock ETH->USDC with default 2% slippage + // ── SSE path (bridge-controller SSE feature flag ON) ────────────────────── + // Catch-all for getQuoteStream with no slippage param (initial render before + // useInitialSlippage fires). Registered first so specific mocks below at + // priority 999 take precedence. Prevents real network calls that cause + // Error: Aborted when the bridge controller aborts the in-flight request. + await setupMockRequest( + mockServer, + { + requestMethod: 'GET', + url: /getQuoteStream/i, + response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), + responseCode: 200, + }, + 1, // lower priority than the specific mocks below (999) + ); + + // Mock ETH->USDC with default 2% slippage (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, + response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE), + responseCode: 200, + }); + + // Mock ETH->USDC with 3.5% custom slippage (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3\.5/i, + response: toSSEResponse(GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE), + responseCode: 200, + }); + + // Mock ETH->DAI (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, + response: toSSEResponse(GET_QUOTE_ETH_DAI_RESPONSE), + responseCode: 200, + }); + + // Mock USDC->USDT (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, + response: toSSEResponse(GET_QUOTE_USDC_USDT_RESPONSE), + responseCode: 200, + }); + + // No quote when destination is mUSD (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + response: '', + responseCode: 200, + }); + + // Mock USDC->ETH (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + response: toSSEResponse(GET_QUOTE_USDC_ETH_RESPONSE), + responseCode: 200, + }); + + // Mock ETH->WETH (SSE) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, + url: /getQuoteStream.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, + response: toSSEResponse(GET_QUOTE_ETH_WETH_RESPONSE), + responseCode: 200, + }); + + // Mock WETH->ETH (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + response: toSSEResponse(GET_QUOTE_WETH_ETH_SAME_CHAIN_RESPONSE), + responseCode: 200, + }); + + // ── JSON path (bridge-controller SSE feature flag OFF) ───────────────────── + // The bridge controller falls back to fetchBridgeQuotes → /getQuote? (no + // "Stream" suffix) returning plain JSON when sse.enabled is false (e.g. local + // dev with BRIDGE_USE_DEV_APIS=true). Use /\/getQuote\?/i so the regex matches + // "getQuote?" but NOT "getQuoteStream?". + + // Catch-all for getQuote (no slippage / initial render) + await setupMockRequest( + mockServer, + { + requestMethod: 'GET', + url: /\/getQuote\?/i, + response: GET_QUOTE_ETH_USDC_RESPONSE, + responseCode: 200, + }, + 1, // lower priority than specific mocks below (999) + ); + + // Mock ETH->USDC with default 2% slippage (JSON) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /\/getQuote\?.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=2/i, response: GET_QUOTE_ETH_USDC_RESPONSE, responseCode: 200, }); - // Mock ETH->USDC with 3.5% custom slippage + // Mock ETH->USDC with 3.5% custom slippage (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3.5/i, + url: /\/getQuote\?.*destTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*slippage=3\.5/i, response: GET_QUOTE_ETH_USDC_RESPONSE_CUSTOM_SLIPPAGE, responseCode: 200, }); - // Mock ETH->DAI + // Mock ETH->DAI (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, + url: /\/getQuote\?.*destTokenAddress=0x6B175474E89094C44Da98b954EedeAC495271d0F/i, response: GET_QUOTE_ETH_DAI_RESPONSE, responseCode: 200, }); - // Mock USDC->USDT + // Mock USDC->USDT (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, + url: /\/getQuote\?.*destTokenAddress=0xdAC17F958D2ee523a2206206994597C13D831ec7/i, response: GET_QUOTE_USDC_USDT_RESPONSE, responseCode: 200, }); - // No need quote when destination is mUSD + // No quote when destination is mUSD (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, + url: /\/getQuote\?.*destTokenAddress=0xacA92E438df0B2401fF60dA7E4337B687a2435DA/i, response: [], responseCode: 200, }); - // Mock USDC->ETH (ETH native token address is 0x0000...0000) + // Mock USDC->ETH (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + url: /\/getQuote\?.*srcTokenAddress=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.*destTokenAddress=0x0000000000000000000000000000000000000000/i, response: GET_QUOTE_USDC_ETH_RESPONSE, responseCode: 200, }); - // Mock ETH->WETH (wrapped native) + // Mock ETH->WETH (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, + url: /\/getQuote\?.*destTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/i, response: GET_QUOTE_ETH_WETH_RESPONSE, responseCode: 200, }); - // Mock WETH->ETH (same-chain unwrap for E2E) + // Mock WETH->ETH (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, + url: /\/getQuote\?.*srcTokenAddress=0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2.*destTokenAddress=0x0000000000000000000000000000000000000000/i, response: GET_QUOTE_WETH_ETH_SAME_CHAIN_RESPONSE, responseCode: 200, }); @@ -169,10 +268,18 @@ export const testSpecificMock: TestSpecificMock = async ( responseCode: 200, }); - // Mock USDC->GOOGLON + // Mock USDC->GOOGLON (SSE) + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: /getQuoteStream.*destTokenAddress=0xba47214edd2bb43099611b208f75e4b42fdcfedc/i, + response: toSSEResponse(GET_QUOTE_USDC_GOOGLON_RESPONSE), + responseCode: 200, + }); + + // Mock USDC->GOOGLON (JSON) await setupMockRequest(mockServer, { requestMethod: 'GET', - url: /getQuote.*destTokenAddress=0xba47214edd2bb43099611b208f75e4b42fdcfedc/i, + url: /\/getQuote\?.*destTokenAddress=0xba47214edd2bb43099611b208f75e4b42fdcfedc/i, response: GET_QUOTE_USDC_GOOGLON_RESPONSE, responseCode: 200, }); diff --git a/yarn.lock b/yarn.lock index 67daa1db5446..707d47d97487 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7707,9 +7707,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:100.0.3, @metamask/assets-controllers@npm:^100.0.2, @metamask/assets-controllers@npm:^100.0.3": - version: 100.0.3 - resolution: "@metamask/assets-controllers@npm:100.0.3" +"@metamask/assets-controllers@npm:^100.0.2, @metamask/assets-controllers@npm:^100.0.3, @metamask/assets-controllers@npm:^100.1.0": + version: 100.1.0 + resolution: "@metamask/assets-controllers@npm:100.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7732,7 +7732,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^7.0.0" "@metamask/network-controller": "npm:^30.0.0" - "@metamask/network-enablement-controller": "npm:^4.1.2" + "@metamask/network-enablement-controller": "npm:^4.2.0" "@metamask/permission-controller": "npm:^12.2.0" "@metamask/phishing-controller": "npm:^16.3.0" "@metamask/polling-controller": "npm:^16.0.3" @@ -7743,7 +7743,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^62.18.0" + "@metamask/transaction-controller": "npm:^62.20.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7759,13 +7759,13 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/fd5ef80f4b3d55bcc5bb98dcf6a1fcbfa4b7401ca2591658b2474f0cd69d69c4b4ba6b5e2e4c9b13d1e3ba55f38e501494601eb8c16e702b715bd32283271a17 + checksum: 10/25a32346b51432bc9d40028719a1b8f0b4a2797458c0fa22f84c1ac3982a4a16e89d7e0b16e1c439377e65621445733b17905b24d4d25c804317b087a19af0ba languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A100.0.3#~/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch": - version: 100.0.3 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A100.0.3#~/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch::version=100.0.3&hash=7df952" +"@metamask/assets-controllers@npm:^99.4.0": + version: 99.4.0 + resolution: "@metamask/assets-controllers@npm:99.4.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7775,23 +7775,23 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^4.1.1" - "@metamask/accounts-controller": "npm:^36.0.1" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.19.0" - "@metamask/core-backend": "npm:^6.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/core-backend": "npm:5.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^7.0.0" - "@metamask/network-controller": "npm:^30.0.0" - "@metamask/network-enablement-controller": "npm:^4.1.2" + "@metamask/network-controller": "npm:^29.0.0" + "@metamask/network-enablement-controller": "npm:^4.1.0" "@metamask/permission-controller": "npm:^12.2.0" - "@metamask/phishing-controller": "npm:^16.3.0" - "@metamask/polling-controller": "npm:^16.0.3" + "@metamask/phishing-controller": "npm:^16.2.0" + "@metamask/polling-controller": "npm:^16.0.2" "@metamask/preferences-controller": "npm:^22.1.0" "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -7799,7 +7799,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^62.18.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@types/bn.js": "npm:^5.1.5" "@types/uuid": "npm:^8.3.0" @@ -7815,7 +7815,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/1b3069b2e5e3e431f54c95e15041fbc464abf003f7d35e094f04b4fcd290a228b9b4998f4c05be9ed92fc683b23b444b4f5713429e8a6d8f3b17d8c01185af7d + checksum: 10/2329ba8efe5a19ebe836c8ddc492f732461078d3954b713e825e4f0f3f5dc5fb17d55f5dabd30a20bd25e33366e8f2358a23b227c182f36688908f0128c5046c languageName: node linkType: hard @@ -9155,9 +9155,9 @@ __metadata: languageName: node linkType: hard -"@metamask/network-enablement-controller@npm:^4.1.0, @metamask/network-enablement-controller@npm:^4.1.1, @metamask/network-enablement-controller@npm:^4.1.2": - version: 4.1.2 - resolution: "@metamask/network-enablement-controller@npm:4.1.2" +"@metamask/network-enablement-controller@npm:^4.1.0, @metamask/network-enablement-controller@npm:^4.1.1, @metamask/network-enablement-controller@npm:^4.2.0": + version: 4.2.0 + resolution: "@metamask/network-enablement-controller@npm:4.2.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" @@ -9166,10 +9166,10 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.0.4" "@metamask/network-controller": "npm:^30.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^62.17.1" + "@metamask/transaction-controller": "npm:^62.20.0" "@metamask/utils": "npm:^11.9.0" reselect: "npm:^5.1.1" - checksum: 10/2860220c63c941173a66be21011c58014421868d1e0e67d05d7ae30c156ac8352d752317e31ac773fa1420a5fdc7126b7c78e317d53d404cd76fd0781cf2c0cb + checksum: 10/293efbfbf6b157e248ea1c7bf5fc70abfde4c47802ff2af25978b723ac3b7e657980566a2a3d1311a263209ea650cfa531482a087660dc6178c09e2491806341 languageName: node linkType: hard @@ -9255,7 +9255,7 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^16.1.0, @metamask/phishing-controller@npm:^16.3.0": +"@metamask/phishing-controller@npm:^16.1.0, @metamask/phishing-controller@npm:^16.2.0, @metamask/phishing-controller@npm:^16.3.0": version: 16.3.0 resolution: "@metamask/phishing-controller@npm:16.3.0" dependencies: @@ -9430,14 +9430,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:@metamask-previews/ramps-controller@10.0.0-preview-225638478": - version: 10.0.0-preview-225638478 - resolution: "@metamask-previews/ramps-controller@npm:10.0.0-preview-225638478" +"@metamask/ramps-controller@npm:^10.2.0": + version: 10.2.0 + resolution: "@metamask/ramps-controller@npm:10.2.0" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/dc093dacbe55ee20cbb50cc9b1c9649469b4c115f35db6b0f7a020610742955c6f35ee5e1f74ba079769c481c0210316ffd23e775bd03b1e83798530aaf142cb + checksum: 10/4c9e10f3948a4e0f44f3a98fd2a7a220585e74793ad4cc899b27be6ea3c428c76fb95b0987697a0dd62a98221868ce2bfb3e40495cef00f4909e5dd88dec152e languageName: node linkType: hard @@ -9737,13 +9737,13 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^18.0.1": - version: 18.0.1 - resolution: "@metamask/snaps-controllers@npm:18.0.1" +"@metamask/snaps-controllers@npm:^18.0.2": + version: 18.0.2 + resolution: "@metamask/snaps-controllers@npm:18.0.2" dependencies: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/json-rpc-engine": "npm:^10.2.2" + "@metamask/json-rpc-engine": "npm:^10.2.3" "@metamask/json-rpc-middleware-stream": "npm:^8.0.8" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" @@ -9776,7 +9776,7 @@ __metadata: peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/0b9c3f8097cda0621020bde7e63a74a20f7623a9926d2d102942df56f9b9fefbf0900f7a3a17b175f8648b1b93fc373f5c736ee88e517ee624ed6161985742cb + checksum: 10/3d8f88ff926b2918b1632dc9920c94871df85146a66d4d6dbb8fb31ee241c7012e85d86bdf30be47aec97fc2fecf724f5e235f30a1550bedf4084586175e05eb languageName: node linkType: hard @@ -10068,9 +10068,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.17.1, @metamask/transaction-controller@npm:^62.18.0, @metamask/transaction-controller@npm:^62.19.0": - version: 62.19.0 - resolution: "@metamask/transaction-controller@npm:62.19.0" +"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@npm:^62.17.1, @metamask/transaction-controller@npm:^62.18.0, @metamask/transaction-controller@npm:^62.19.0, @metamask/transaction-controller@npm:^62.20.0": + version: 62.20.0 + resolution: "@metamask/transaction-controller@npm:62.20.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -10090,7 +10090,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^30.0.0" "@metamask/nonce-tracker": "npm:^6.0.0" - "@metamask/remote-feature-flag-controller": "npm:^4.0.0" + "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" async-mutex: "npm:^0.5.0" @@ -10103,7 +10103,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/d98cff056d00830a962dfd41f26211334fe52cf7a3f2833ad3e542303d7c43011918760f37d5a9dd145088b9c45376914de6a523716686897e899842a5d19038 + checksum: 10/5b0f1af0cf484d39ff29c674bdaed864c6301b98f247cd8c647ac7c51838a27d3b4ddea1bf21c5931e0ce5f60db5f0afb38924e746261afcafb12e28a8581cd6 languageName: node linkType: hard @@ -35378,7 +35378,7 @@ __metadata: "@metamask/app-metadata-controller": "npm:^2.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/assets-controller": "npm:^2.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A100.0.3#~/.yarn/patches/@metamask-assets-controllers-npm-100.0.3-ea172b88c9.patch" + "@metamask/assets-controllers": "npm:^100.1.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.10.0" @@ -35452,7 +35452,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^10.0.0" + "@metamask/ramps-controller": "npm:^10.2.0" "@metamask/react-native-acm": "npm:^1.0.1" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0" @@ -35470,7 +35470,7 @@ __metadata: "@metamask/signature-controller": "npm:^35.0.0" "@metamask/slip44": "npm:^4.2.0" "@metamask/smart-transactions-controller": "npm:^22.6.0" - "@metamask/snaps-controllers": "npm:^18.0.1" + "@metamask/snaps-controllers": "npm:^18.0.2" "@metamask/snaps-execution-environments": "npm:^11.0.0" "@metamask/snaps-rpc-methods": "npm:^14.3.0" "@metamask/snaps-sdk": "npm:^10.4.0" @@ -35775,7 +35775,7 @@ __metadata: redux-devtools-expo-dev-plugin: "npm:^1.0.0" redux-mock-store: "npm:1.5.4" redux-persist: "npm:6.0.0" - redux-persist-filesystem-storage: "npm:^4.2.0" + redux-persist-filesystem-storage: "patch:redux-persist-filesystem-storage@npm%3A4.2.0#~/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch" redux-saga: "npm:^1.3.0" redux-saga-test-plan: "npm:^4.0.6" redux-thunk: "npm:^2.4.2" @@ -41517,7 +41517,7 @@ __metadata: languageName: node linkType: hard -"redux-persist-filesystem-storage@npm:^4.2.0": +"redux-persist-filesystem-storage@npm:4.2.0": version: 4.2.0 resolution: "redux-persist-filesystem-storage@npm:4.2.0" dependencies: @@ -41526,6 +41526,15 @@ __metadata: languageName: node linkType: hard +"redux-persist-filesystem-storage@patch:redux-persist-filesystem-storage@npm%3A4.2.0#~/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch": + version: 4.2.0 + resolution: "redux-persist-filesystem-storage@patch:redux-persist-filesystem-storage@npm%3A4.2.0#~/.yarn/patches/redux-persist-filesystem-storage-npm-4.2.0-3a6fff24ab.patch::version=4.2.0&hash=4dfd27" + dependencies: + react-native-blob-util: "npm:^0.18.0" + checksum: 10/fa556e2d1784a5e664e2e7024fa2255b08334e0dacf8993acca676cb912ad82c0f8ef3ba9ec2597d455f9dded83acf3343cc0a66d4e2fc14486e31dd9efe6def + languageName: node + linkType: hard + "redux-persist@npm:6.0.0": version: 6.0.0 resolution: "redux-persist@npm:6.0.0"