diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index da4416ec12b0..3463e1d0e2d7 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -128,6 +128,7 @@ const getStories = () => { "./app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx": require("../app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx"), "./app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx"), "./app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx"), + "./app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx": require("../app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx"), "./app/components/Views/QRAccountDisplay/QRAccountDisplay.stories.tsx": require("../app/components/Views/QRAccountDisplay/QRAccountDisplay.stories.tsx"), }; }; diff --git a/app/actions/sendFlow/index.test.ts b/app/actions/sendFlow/index.test.ts deleted file mode 100644 index 21ea20b4c006..000000000000 --- a/app/actions/sendFlow/index.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Hex } from '@metamask/utils'; -import { - SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - setTransactionSendFlowContextualChainId, -} from './index'; - -describe('SendFlow Actions', () => { - describe('setTransactionSendFlowContextualChainId', () => { - it('should create action with chain ID', () => { - const chainId: Hex = '0x1'; - const expectedAction = { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId, - }; - - expect(setTransactionSendFlowContextualChainId(chainId)).toEqual( - expectedAction, - ); - }); - - it('should handle different chain ID formats', () => { - const testCases: { input: Hex; expected: Hex }[] = [ - { input: '0x1', expected: '0x1' }, - { input: '0xa', expected: '0xa' }, - { input: '0x38', expected: '0x38' }, - { input: '0x89', expected: '0x89' }, - { input: '0xa86a', expected: '0xa86a' }, - ]; - - testCases.forEach(({ input, expected }) => { - const action = setTransactionSendFlowContextualChainId(input); - expect(action.chainId).toBe(expected); - expect(action.type).toBe(SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID); - }); - }); - - it('should handle common network chain IDs', () => { - const networkChainIds: { name: string; chainId: Hex }[] = [ - { name: 'Ethereum Mainnet', chainId: '0x1' }, - { name: 'Polygon', chainId: '0x89' }, - { name: 'BSC', chainId: '0x38' }, - { name: 'Avalanche', chainId: '0xa86a' }, - { name: 'Arbitrum', chainId: '0xa4b1' }, - ]; - - networkChainIds.forEach(({ chainId }) => { - const action = setTransactionSendFlowContextualChainId(chainId); - expect(action.chainId).toBe(chainId); - expect(action.type).toBe(SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID); - }); - }); - }); -}); diff --git a/app/actions/sendFlow/index.ts b/app/actions/sendFlow/index.ts deleted file mode 100644 index 074362e6279b..000000000000 --- a/app/actions/sendFlow/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Hex } from '@metamask/utils'; - -export const SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID = - 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID'; - -export function setTransactionSendFlowContextualChainId(chainId: Hex) { - return { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId, - }; -} diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.stories.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.stories.tsx index 3b3626e6f163..0ef7b2e9ca15 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.stories.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.stories.tsx @@ -24,17 +24,17 @@ const createMockAccountGroup = ( } as AccountGroupObject); const mockAccountGroup1 = createMockAccountGroup( - 'test-group1', + 'wallet-1/group-1', 'Test Group 1', ['account-id-1'], ); const mockAccountGroup2 = createMockAccountGroup( - 'test-group2', + 'wallet-1/group-2', 'Test Group 2', ['account-id-2'], ); const mockAccountGroup3 = createMockAccountGroup( - 'test-group3', + 'wallet-2/group-3', 'Test Group 3', ['account-id-3'], ); @@ -51,18 +51,19 @@ const mockStore = configureStore({ id: 'wallet-1', metadata: { name: 'MetaMask Wallet' }, groups: { - 'group-1': mockAccountGroup1, - 'group-2': mockAccountGroup2, + 'wallet-1/group-1': mockAccountGroup1, + 'wallet-1/group-2': mockAccountGroup2, }, }, 'wallet-2': { id: 'wallet-2', metadata: { name: 'Hardware Wallet' }, groups: { - 'group-3': mockAccountGroup3, + 'wallet-2/group-3': mockAccountGroup3, }, }, }, + selectedAccountGroup: 'wallet-1/group-1', }, }, AccountsController: { @@ -140,9 +141,9 @@ const MultichainAccountSelectorListMeta = { ], argTypes: { onSelectAccount: { action: 'account selected' }, - selectedAccountGroup: { + selectedAccountGroups: { control: { type: 'object' }, - defaultValue: mockAccountGroup1, + defaultValue: [mockAccountGroup1], }, testID: { control: { type: 'text' }, @@ -163,7 +164,25 @@ export default MultichainAccountSelectorListMeta; export const Default = { args: { - selectedAccountGroup: mockAccountGroup1, - privacyMode: false, + selectedAccountGroups: [mockAccountGroup1], + }, +}; + +export const MultipleSelected = { + args: { + selectedAccountGroups: [mockAccountGroup1, mockAccountGroup2], + }, +}; + +export const NoSelection = { + args: { + selectedAccountGroups: [], + }, +}; + +export const WithCustomTestID = { + args: { + selectedAccountGroups: [mockAccountGroup1], + testID: 'custom-multichain-account-selector', }, }; diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx index 21f81d1a3731..f604c0d50fa1 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.test.tsx @@ -63,14 +63,14 @@ describe('MultichainAccountSelectorList', () => { const renderComponentWithMockState = ( wallets: AccountWalletObject[], internalAccounts: Record, - selectedAccountGroup: AccountGroupObject, + selectedAccountGroups: AccountGroupObject[], ) => { const mockState = createMockState(wallets, internalAccounts); return renderWithProvider( , { state: mockState }, ); @@ -89,7 +89,7 @@ describe('MultichainAccountSelectorList', () => { const { getByText } = renderComponentWithMockState( [wallet1, wallet2], internalAccounts, - account1, + [account1], ); expect(getByText('Wallet 1')).toBeTruthy(); @@ -111,7 +111,7 @@ describe('MultichainAccountSelectorList', () => { const { getByText } = renderComponentWithMockState( [srpWallet, snapWallet], internalAccounts, - srpAccount, + [srpAccount], ); expect(getByText('Wallet 1')).toBeTruthy(); @@ -136,7 +136,7 @@ describe('MultichainAccountSelectorList', () => { const { getByText } = renderComponentWithMockState( [srpWallet, ledgerWallet], internalAccounts, - srpAccount, + [srpAccount], ); expect(getByText('Wallet 1')).toBeTruthy(); @@ -158,7 +158,7 @@ describe('MultichainAccountSelectorList', () => { const { getAllByTestId } = renderComponentWithMockState( [wallet1], internalAccounts, - account2, + [account2], ); const accountCells = getAllByTestId('multichain-account-cell-container'); @@ -192,7 +192,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Initially all accounts should be visible @@ -229,7 +229,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Initially all accounts should be visible @@ -284,7 +284,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Initially all accounts should be visible @@ -329,7 +329,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Initially all groups should be visible @@ -370,7 +370,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1, wallet2], internalAccounts, - account1, + [account1], ); // Initially all accounts should be visible @@ -403,7 +403,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, getByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Search for non-existent term @@ -441,7 +441,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); // Search with different cases @@ -485,7 +485,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); const searchInput = getByTestId( @@ -532,7 +532,7 @@ describe('MultichainAccountSelectorList', () => { const { getByTestId, queryByText } = renderComponentWithMockState( [wallet1], internalAccounts, - account1, + [account1], ); const searchInput = getByTestId( diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx index d42b249948e9..0cebc49b7e49 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx @@ -29,7 +29,7 @@ import { strings } from '../../../../../locales/i18n'; const MultichainAccountSelectorList = ({ onSelectAccount, - selectedAccountGroup, + selectedAccountGroups, testID = MULTICHAIN_ACCOUNT_SELECTOR_LIST_TESTID, listRef, ...props @@ -44,6 +44,11 @@ const MultichainAccountSelectorList = ({ const [searchText, setSearchText] = useState(''); const [debouncedSearchText, setDebouncedSearchText] = useState(''); + const selectedIdSet = useMemo( + () => new Set(selectedAccountGroups.map((g) => g.id)), + [selectedAccountGroups], + ); + // Debounce search text with 200ms delay useEffect(() => { const timer = setTimeout(() => { @@ -137,11 +142,10 @@ const MultichainAccountSelectorList = ({ // Handle account selection with debouncing to prevent rapid successive calls const handleSelectAccount = useCallback( (accountGroup: AccountGroupObject) => { - // Prevent multiple rapid calls for the same account - if (selectedAccountGroup.id === accountGroup.id) return; + if (selectedIdSet.has(accountGroup.id)) return; onSelectAccount?.(accountGroup); }, - [onSelectAccount, selectedAccountGroup.id], + [onSelectAccount, selectedIdSet], ); const renderItem: ListRenderItem = @@ -153,7 +157,7 @@ const MultichainAccountSelectorList = ({ } case 'cell': { - const isSelected = item.data.id === selectedAccountGroup.id; + const isSelected = selectedIdSet.has(item.data.id); return ( = { ...MOCK_KEYRING_CONTROLLER_STATE, isUnlocked: true, }, - NetworkController: { - selectedNetworkClientId: 'mainnet', - networksMetadata: { - mainnet: { - EIPS: { - 1559: true, - }, - status: NetworkStatus.Available, - }, - }, - networkConfigurationsByChainId: { - [CHAIN_IDS.MAINNET]: { - chainId: CHAIN_IDS.MAINNET, - name: 'Ethereum Main Network', - nativeCurrency: 'ETH', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'mainnet', - type: RpcEndpointType.Infura, - url: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }, - ], - }, - }, - }, - MultichainNetworkController: { - isEvmSelected: true, - }, }, }, }; @@ -137,13 +107,6 @@ jest.mock('../../../core/Engine', () => { }; }); -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - getNetworkNameFromProviderConfig: jest.fn(() => NETWORK_NAME_MOCK), - isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false), - isPerDappSelectedNetworkEnabled: jest.fn(() => false), -})); - jest.mock('../../Views/confirmations/hooks/useNetworkInfo', () => ({ __esModule: true, default: jest.fn(() => ({ diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.tsx index b2aa6645b549..85fe53586e72 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.tsx @@ -21,21 +21,10 @@ import { AccountFromToInfoCardProps } from './AccountFromToInfoCard.types'; import { selectInternalAccounts } from '../../../selectors/accountsController'; import { RootState } from '../../../reducers'; import AddressFrom from './AddressFrom'; -import { - isPerDappSelectedNetworkEnabled, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; -import { selectSendFlowContextualChainId } from '../../../selectors/sendFlow'; +import { isPerDappSelectedNetworkEnabled } from '../../../util/networks'; const AccountFromToInfoCard = (props: AccountFromToInfoCardProps) => { - const { - internalAccounts, - ticker, - transactionState, - origin, - chainId: globalChainId, - contextualChainId, - } = props; + const { internalAccounts, chainId, ticker, transactionState, origin } = props; const { transaction: { from: rawFromAddress, data, to }, transactionTo, @@ -44,9 +33,6 @@ const AccountFromToInfoCard = (props: AccountFromToInfoCardProps) => { ensRecipient, } = transactionState; - const chainId = isRemoveGlobalNetworkSelectorEnabled() - ? contextualChainId || globalChainId - : globalChainId; const fromAddress = toFormattedAddress(rawFromAddress); const [toAddress, setToAddress] = useState(transactionTo || to); @@ -218,7 +204,6 @@ const AccountFromToInfoCard = (props: AccountFromToInfoCardProps) => { const mapStateToProps = (state: RootState) => ({ internalAccounts: selectInternalAccounts(state), chainId: selectEvmChainId(state), - contextualChainId: selectSendFlowContextualChainId(state), ticker: selectEvmTicker(state), }); diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.types.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.types.tsx index 243f1cc62512..17deaa4ec8e9 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.types.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.types.tsx @@ -35,5 +35,4 @@ export interface AccountFromToInfoCardProps { icon: string; }; url: string; - contextualChainId: string; } diff --git a/app/components/UI/AccountFromToInfoCard/AddressFrom.test.tsx b/app/components/UI/AccountFromToInfoCard/AddressFrom.test.tsx deleted file mode 100644 index d01d3a4b9a80..000000000000 --- a/app/components/UI/AccountFromToInfoCard/AddressFrom.test.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; -import { render } from '@testing-library/react-native'; - -import renderWithProvider, { - DeepPartial, -} from '../../../util/test/renderWithProvider'; -import AddressFrom from './AddressFrom'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { createMockAccountsControllerState } from '../../../util/test/accountsControllerTestUtils'; -import { RootState } from '../../../reducers'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; -import { MOCK_KEYRING_CONTROLLER_STATE } from '../../../util/test/keyringControllerTestUtils'; -import { RpcEndpointType } from '@metamask/network-controller'; -import { - getNetworkImageSource, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; - -const MOCK_ADDRESS_1 = '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A'; -const MOCK_ADDRESS_2 = '0x519d2CE57898513F676a5C3b66496c3C394c9CC7'; - -const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ - MOCK_ADDRESS_1, - MOCK_ADDRESS_2, -]); - -const mockAsset = { - isETH: true, - address: '0x0', - symbol: 'ETH', - decimals: 18, -}; - -const mockInitialState: DeepPartial = { - settings: { - useBlockieIcon: false, - }, - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - [CHAIN_IDS.MAINNET]: { - [MOCK_ADDRESS_1]: { - balance: '0x1bc16d674ec80000', - }, - [MOCK_ADDRESS_2]: { - balance: '0x6f05b59d3b20000', - }, - }, - '0x38': { - [MOCK_ADDRESS_1]: { - balance: '0x8ac7230489e80000', - }, - }, - }, - }, - TokenBalancesController: { - tokenBalances: {}, - }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - KeyringController: { - vault: 'mock-vault', - ...MOCK_KEYRING_CONTROLLER_STATE, - isUnlocked: true, - }, - NetworkController: { - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: { - [CHAIN_IDS.MAINNET]: { - chainId: CHAIN_IDS.MAINNET, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'mainnet', - type: RpcEndpointType.Infura, - }, - ], - }, - '0x38': { - chainId: '0x38', - name: 'BNB Smart Chain', - nativeCurrency: 'BNB', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'bsc-mainnet', - url: 'https://bsc-dataseed.binance.org/', - type: RpcEndpointType.Custom, - }, - ], - }, - }, - }, - }, - }, -}; - -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn(), - getNetworkImageSource: jest.fn(), -})); - -jest.mock('../../../selectors/selectedNetworkController', () => ({ - ...jest.requireActual('../../../selectors/selectedNetworkController'), - useNetworkInfo: jest.fn(() => ({ - networkImageSource: 'per-dapp-network-image.png', - networkName: 'Per-dapp Network', - chainId: '0x1', - rpcUrl: 'https://mainnet.infura.io/v3/', - domainNetworkClientId: 'mainnet', - domainIsConnectedDapp: true, - })), -})); - -jest.mock('../../../util/address', () => ({ - ...jest.requireActual('../../../util/address'), - toChecksumAddress: jest.fn((address) => address), - getLabelTextByAddress: jest.fn(() => 'External'), - renderAccountName: jest.fn(() => 'Account 1'), -})); - -jest.mock('../../hooks/useAddressBalance/useAddressBalance', () => ({ - __esModule: true, - default: jest.fn(() => ({ - addressBalance: '2 ETH', - })), -})); - -const mockStore = configureMockStore(); - -describe('AddressFrom', () => { - const defaultProps = { - asset: mockAsset, - from: MOCK_ADDRESS_1, - dontWatchAsset: false, - }; - const mockIsRemoveGlobalNetworkSelectorEnabled = - isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction< - typeof isRemoveGlobalNetworkSelectorEnabled - >; - - beforeEach(() => { - jest.clearAllMocks(); - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - }); - - describe('Basic Rendering', () => { - it('should render correctly with default props', () => { - const store = mockStore(mockInitialState); - - const component = render( - - - , - ); - - expect(component).toMatchSnapshot(); - }); - - it('should match snapshot with global network configuration', async () => { - const component = renderWithProvider(, { - state: mockInitialState, - }); - - expect(component).toMatchSnapshot(); - }); - - it('displays from label correctly', async () => { - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText(/From:/)).toBeDefined(); - }); - - it('displays account name correctly', async () => { - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('Account 1')).toBeDefined(); - }); - }); - - describe('Network Display Logic', () => { - it('displays global network name when no origin or contextual network disabled', async () => { - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('Ethereum Main Network')).toBeDefined(); - }); - - it('displays per-dapp network when origin is provided', async () => { - const propsWithOrigin = { - ...defaultProps, - origin: 'https://dapp.example.com', - }; - - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('Per-dapp Network')).toBeDefined(); - }); - - it('displays contextual network when enabled and chainId provided', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const propsWithChainId = { - ...defaultProps, - chainId: '0x38', - }; - - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('BNB Smart Chain')).toBeDefined(); - }); - }); - - describe('Different Asset Types', () => { - it('handles ERC20 token correctly', async () => { - const erc20Asset = { - isETH: false, - address: '0xA0b86a33E6441c8C5fCdE7dA1A1f00Ecadb522e6c', - symbol: 'USDT', - decimals: 6, - }; - - const propsWithToken = { - ...defaultProps, - asset: erc20Asset, - }; - - const component = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(component).toMatchSnapshot(); - }); - - it('handles NFT asset correctly', async () => { - const nftAsset = { - isETH: false, - address: '0x26D6C3e7aEFCE970fe3BE5d589DbAbFD30026924', - symbol: 'NFT', - decimals: 0, - tokenId: '12345', - standard: 'ERC721', - }; - - const propsWithNFT = { - ...defaultProps, - asset: nftAsset, - }; - - const component = renderWithProvider(, { - state: mockInitialState, - }); - - expect(component).toMatchSnapshot(); - }); - }); - - describe('Edge Cases', () => { - it('handles missing chainId gracefully', async () => { - const propsWithoutChainId = { - ...defaultProps, - chainId: undefined, - }; - - const { queryByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(queryByText('null')).toBeDefined(); - }); - - it('handles invalid chainId gracefully', async () => { - const propsWithInvalidChainId = { - ...defaultProps, - chainId: '0x999999', - }; - - const component = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(component).toBeDefined(); - }); - - it('handles missing network configuration gracefully', async () => { - const stateWithoutNetworkConfig = { - ...mockInitialState, - engine: { - ...mockInitialState.engine, - backgroundState: { - ...mockInitialState.engine?.backgroundState, - NetworkController: { - ...mockInitialState.engine?.backgroundState?.NetworkController, - networkConfigurationsByChainId: {}, - }, - }, - }, - }; - - const { queryByText } = renderWithProvider( - , - { state: stateWithoutNetworkConfig }, - ); - - expect(queryByText('null')).toBeDefined(); - }); - }); - - describe('Props Validation', () => { - it('handles dontWatchAsset prop correctly', async () => { - const propsWithDontWatch = { - ...defaultProps, - dontWatchAsset: true, - }; - - const component = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(component).toBeDefined(); - }); - - it('handles different from addresses correctly', async () => { - const propsWithDifferentAddress = { - ...defaultProps, - from: MOCK_ADDRESS_2, - }; - - const component = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(component).toBeDefined(); - }); - }); - - describe('Badge Props', () => { - it('creates badge props with correct network variant', async () => { - const component = renderWithProvider(, { - state: mockInitialState, - }); - - expect(component).toBeDefined(); - }); - - it('handles missing network image gracefully', async () => { - const mockGetNetworkImageSource = - getNetworkImageSource as jest.MockedFunction< - typeof getNetworkImageSource - >; - mockGetNetworkImageSource.mockReturnValue(''); - - const component = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(component).toBeDefined(); - }); - }); - - describe('Feature Flag Interactions', () => { - it('uses contextual network when feature flag is enabled', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const propsWithChainId = { - ...defaultProps, - chainId: CHAIN_IDS.MAINNET, - }; - - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('Ethereum Mainnet')).toBeDefined(); - }); - - it('falls back to global network when feature flag is disabled', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - const { findByText } = renderWithProvider( - , - { state: mockInitialState }, - ); - - expect(await findByText('Ethereum Main Network')).toBeDefined(); - }); - }); -}); diff --git a/app/components/UI/AccountFromToInfoCard/AddressFrom.tsx b/app/components/UI/AccountFromToInfoCard/AddressFrom.tsx index bf7d42baf1f5..0f215fbbf1c0 100644 --- a/app/components/UI/AccountFromToInfoCard/AddressFrom.tsx +++ b/app/components/UI/AccountFromToInfoCard/AddressFrom.tsx @@ -1,15 +1,17 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; -import { toHex } from '@metamask/controller-utils'; import { strings } from '../../../../locales/i18n'; import AccountBalance from '../../../component-library/components-temp/Accounts/AccountBalance'; import { BadgeVariant } from '../../../component-library/components/Badges/Badge'; import Text from '../../../component-library/components/Texts/Text'; import { useStyles } from '../../../component-library/hooks'; -import { selectNetworkConfigurationByChainId } from '../../../selectors/networkController'; -import { RootState } from '../../../reducers'; +import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; +import { + selectEvmNetworkImageSource, + selectEvmNetworkName, +} from '../../../selectors/networkInfos'; import { getLabelTextByAddress, renderAccountName, @@ -18,15 +20,8 @@ import { import useAddressBalance from '../../hooks/useAddressBalance/useAddressBalance'; import stylesheet from './AddressFrom.styles'; import { selectInternalEvmAccounts } from '../../../selectors/accountsController'; -import { - isRemoveGlobalNetworkSelectorEnabled, - getNetworkImageSource, -} from '../../../util/networks'; -import { - selectEvmNetworkImageSource, - selectEvmNetworkName, -} from '../../../selectors/networkInfos'; -import { useNetworkInfo } from '../../../selectors/selectedNetworkController'; +import useNetworkInfo from '../../Views/confirmations/hooks/useNetworkInfo'; +import { isPerDappSelectedNetworkEnabled } from '../../../util/networks'; interface Asset { isETH?: boolean; @@ -61,51 +56,22 @@ const AddressFrom = ({ asset, from, dontWatchAsset, - chainId, + isPerDappSelectedNetworkEnabled() ? chainId : undefined, ); - const hexChainId = useMemo( - () => (chainId ? toHex(chainId) : null), - [chainId], - ); - const networkConfiguration = useSelector((state: RootState) => - hexChainId ? selectNetworkConfigurationByChainId(state, hexChainId) : null, - ); + const accountsByChainId = useSelector(selectAccountsByChainId); const internalAccounts = useSelector(selectInternalEvmAccounts); + const activeAddress = toChecksumAddress(from); - const globalNetworkName = useSelector(selectEvmNetworkName); - const globalNetworkImage = useSelector(selectEvmNetworkImageSource); + const networkName = useSelector(selectEvmNetworkName); + const networkImage = useSelector(selectEvmNetworkImageSource); + const perDappNetworkInfo = useNetworkInfo(chainId); const useBlockieIcon = useSelector( - (state: RootState) => state.settings.useBlockieIcon, - ); - - const activeAddress = useMemo(() => toChecksumAddress(from), [from]); - const isContextualNetworkEnabled = useMemo( - () => isRemoveGlobalNetworkSelectorEnabled(), - [], - ); - - const perDappNetworkInfo = useNetworkInfo(origin || ''); - const { networkName, networkImageSource } = perDappNetworkInfo || {}; - - const sendFlowNetworkData = useMemo(() => { - if (!isContextualNetworkEnabled) { - return { name: null, imageSource: null }; - } - - const name = networkConfiguration?.name || null; - const imageSource = hexChainId - ? getNetworkImageSource({ chainId: hexChainId }) - : null; - - return { name, imageSource }; - }, [isContextualNetworkEnabled, networkConfiguration, hexChainId]); - - const accountTypeLabel = useMemo( - () => getLabelTextByAddress(activeAddress), - [activeAddress], + // TODO: Replace "any" with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state: any) => state.settings.useBlockieIcon, ); useEffect(() => { @@ -113,66 +79,41 @@ const AddressFrom = ({ ? renderAccountName(activeAddress, internalAccounts) : ''; setAccountName(accountNameVal); - }, [activeAddress, internalAccounts]); - const displayNetworkName = useMemo(() => { - if (origin && networkName) { - return networkName; - } - if (isContextualNetworkEnabled) { - return sendFlowNetworkData.name; - } - return globalNetworkName; - }, [ - origin, - networkName, - isContextualNetworkEnabled, - sendFlowNetworkData, - globalNetworkName, - ]); - - const displayNetworkImage = useMemo(() => { - if (origin && networkImageSource) { - return networkImageSource; + if (!origin) { + return; } - if (isContextualNetworkEnabled) { - return sendFlowNetworkData.imageSource; - } - return globalNetworkImage; - }, [ - origin, - networkImageSource, - isContextualNetworkEnabled, - sendFlowNetworkData, - globalNetworkImage, - ]); - - const badgeProps = useMemo( - () => ({ - variant: BadgeVariant.Network as const, - name: displayNetworkName || undefined, - imageSource: displayNetworkImage || undefined, - }), - [displayNetworkName, displayNetworkImage], - ); + }, [accountsByChainId, internalAccounts, activeAddress, origin]); + + const displayNetworkName = isPerDappSelectedNetworkEnabled() + ? perDappNetworkInfo.networkName + : networkName; - const accountBalanceLabel = useMemo(() => strings('transaction.balance'), []); + const displayNetworkImage = isPerDappSelectedNetworkEnabled() + ? perDappNetworkInfo.networkImage + : networkImage; - const fromLabel = useMemo(() => strings('transaction.fromWithColon'), []); + const accountTypeLabel = getLabelTextByAddress(activeAddress); return ( - {fromLabel} + + {strings('transaction.fromWithColon')} + diff --git a/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap b/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap index 5def7b0a681f..21419520df3f 100644 --- a/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap +++ b/app/components/UI/AccountFromToInfoCard/__snapshots__/AccountFromToInfoCard.test.tsx.snap @@ -290,7 +290,7 @@ exports[`AccountFromToInfoCard should match snapshot 1`] = ` - - - From: - - - - - - - - - - - - - - - - - - - - - - - - E - - - - - - - - Ethereum Main Network - - - - Account 1 - - - - - External - - - - - - - Balance - - - 2 ETH - - - - - -`; - -exports[`AddressFrom Basic Rendering should render correctly with default props 1`] = ` - - - - From: - - - - - - - - - - - - - - - - - - - - - - - - E - - - - - - - - Ethereum Main Network - - - - Account 1 - - - - - External - - - - - - - Balance - - - 2 ETH - - - - - -`; - -exports[`AddressFrom Different Asset Types handles ERC20 token correctly 1`] = ` - - - - From: - - - - - - - - - - - - - - - - - - - - - - - - E - - - - - - - - Ethereum Main Network - - - - Account 1 - - - - - External - - - - - - - Balance - - - 2 ETH - - - - - -`; - -exports[`AddressFrom Different Asset Types handles NFT asset correctly 1`] = ` - - - - From: - - - - - - - - - - - - - - - - - - - - - - - - E - - - - - - - - Ethereum Main Network - - - - Account 1 - - - - - External - - - - - - - Balance - - - 2 ETH - - - - - -`; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 83884852f4c8..58b2edebdfa3 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -69,7 +69,6 @@ import { trace, TraceName, TraceOperation } from '../../../util/trace'; import { getTraceTags } from '../../../util/sentry/tags'; import { store } from '../../../store'; import CardButton from '../Card/components/CardButton'; -import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; const trackEvent = (event, params = {}) => { MetaMetrics.getInstance().trackEvent(event); @@ -555,7 +554,7 @@ export function getSendFlowTitle( transaction, disableNetwork = true, showSelectedNetwork = false, - sendFlowContextualChainId = '', + globalChainId = '', ) { const innerStyles = StyleSheet.create({ headerButtonText: { @@ -603,14 +602,7 @@ export function getSendFlowTitle( : undefined } networkName={ - isRemoveGlobalNetworkSelectorEnabled() - ? sendFlowContextualChainId - : undefined - } - source={ - isRemoveGlobalNetworkSelectorEnabled() - ? NETWORK_SELECTOR_SOURCES.SEND_FLOW - : undefined + isRemoveGlobalNetworkSelectorEnabled() ? globalChainId : undefined } /> ), @@ -1659,7 +1651,12 @@ export function getSwapsAmountNavbar(navigation, route, themeColors) { const title = route.params?.title ?? 'Swap'; return { headerTitle: () => ( - + ), headerLeft: () => , headerRight: () => ( diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index be20ea66329b..9d0803818d63 100644 --- a/app/components/UI/Navbar/index.test.jsx +++ b/app/components/UI/Navbar/index.test.jsx @@ -1,6 +1,7 @@ /* eslint-disable react/prop-types */ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; +import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import { diff --git a/app/components/UI/NavbarTitle/index.js b/app/components/UI/NavbarTitle/index.js index beb20ac6ef31..f61163a08fcf 100644 --- a/app/components/UI/NavbarTitle/index.js +++ b/app/components/UI/NavbarTitle/index.js @@ -18,7 +18,6 @@ import Text, { TextColor, } from '../../../component-library/components/Texts/Text'; import { selectNetworkName } from '../../../selectors/networkInfos'; -import { NETWORK_SELECTOR_SOURCE_VALUES } from '../../../constants/networkSelector'; const createStyles = (colors) => StyleSheet.create({ @@ -82,10 +81,6 @@ class NavbarTitle extends PureComponent { * Selected network name */ selectedNetworkName: PropTypes.string, - /** - * Source of the network selector - */ - source: PropTypes.oneOf(NETWORK_SELECTOR_SOURCE_VALUES), }; static defaultProps = { @@ -101,18 +96,13 @@ class NavbarTitle extends PureComponent { this.animating = true; this.props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SHEET.NETWORK_SELECTOR, - params: { - source: this.props.source, - }, }); this.props.metrics.trackEvent( this.props.metrics .createEventBuilder(MetaMetricsEvents.NETWORK_SELECTOR_PRESSED) .addProperties({ - // TODO: if contextual chainId is used, the providerConfig is the chain needed for this tracking chain_id: getDecimalChainId(this.props.chainId), - source: this.props.source, }) .build(), ); diff --git a/app/components/UI/NetworkMainAssetLogo/index.js b/app/components/UI/NetworkMainAssetLogo/index.js index f2c07aabe2e1..abfce750a73a 100644 --- a/app/components/UI/NetworkMainAssetLogo/index.js +++ b/app/components/UI/NetworkMainAssetLogo/index.js @@ -38,9 +38,9 @@ function NetworkMainAssetLogo({ ); } -const mapStateToProps = (state, ownProps) => ({ - chainId: ownProps.chainId || selectChainId(state), - ticker: ownProps.ticker || selectEvmTicker(state), +const mapStateToProps = (state) => ({ + chainId: selectChainId(state), + ticker: selectEvmTicker(state), }); NetworkMainAssetLogo.propTypes = { diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx index 5b3b5f35c6cd..a58e68ce63ca 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Routes from '../../../../../constants/navigation/Routes'; import PerpsTutorialCarousel, { PERPS_RIVE_ARTBOARD_NAMES, } from './PerpsTutorialCarousel'; import { strings } from '../../../../../../locales/i18n'; +import { PERFORMANCE_CONFIG } from '../../constants/perpsConfig'; // Mock .riv file to prevent Jest parsing binary data jest.mock( @@ -51,6 +52,7 @@ jest.mock('rive-react-native', () => { // Mock dependencies jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), + useRoute: jest.fn(), })); jest.mock('react-native-safe-area-context', () => ({ @@ -120,17 +122,25 @@ describe('PerpsTutorialCarousel', () => { const mockNavigation = { navigate: jest.fn(), goBack: jest.fn(), + setParams: jest.fn(), }; beforeEach(() => { jest.clearAllMocks(); + jest.useFakeTimers(); mockMarkTutorialCompleted.mockClear(); mockTrack.mockClear(); mockDepositWithConfirmation.mockClear(); (useNavigation as jest.Mock).mockReturnValue(mockNavigation); + (useRoute as jest.Mock).mockReturnValue({ params: {} }); (useSafeAreaInsets as jest.Mock).mockReturnValue({ top: 0, bottom: 0 }); }); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + describe('Component Rendering', () => { it('renders correct artboard names for each tutorial screen', async () => { const expectedArtboards = [ @@ -226,9 +236,7 @@ describe('PerpsTutorialCarousel', () => { const continueButton = screen.getByText( strings('perps.tutorial.continue'), ); - await act(async () => { - fireEvent.press(continueButton); - }); + fireEvent.press(continueButton); } // Verify we're on the last screen @@ -300,4 +308,144 @@ describe('PerpsTutorialCarousel', () => { expect(mockDepositWithConfirmation).toHaveBeenCalled(); }); }); + + describe('Deeplink Navigation', () => { + it('should navigate to wallet home with Perps tab when skipping from deeplink', () => { + // Mock route params to indicate deeplink origin + (useRoute as jest.Mock).mockReturnValue({ + params: { + isFromDeeplink: true, + }, + }); + + render(); + + // Press skip button + act(() => { + fireEvent.press(screen.getByText(strings('perps.tutorial.skip'))); + }); + + // Should navigate to wallet home instead of goBack + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + + // Fast-forward timer to trigger setParams + jest.advanceTimersByTime(PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + + // Should set params to select Perps tab + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + initialTab: 'perps', + shouldSelectPerpsTab: true, + }); + }); + + it('should navigate to wallet home with Perps tab when skipping from last screen with deeplink', async () => { + // Mock route params to indicate deeplink origin + (useRoute as jest.Mock).mockReturnValue({ + params: { + isFromDeeplink: true, + }, + }); + + render(); + + // Navigate to the last screen + for (let i = 0; i < 5; i++) { + const continueButton = screen.getByText( + strings('perps.tutorial.continue'), + ); + fireEvent.press(continueButton); + } + + // Press "Got it" button on last screen + act(() => { + fireEvent.press(screen.getByText(strings('perps.tutorial.got_it'))); + }); + + // Should mark tutorial as completed + expect(mockMarkTutorialCompleted).toHaveBeenCalled(); + + // Should navigate to wallet home with Perps tab + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); + + // Fast-forward timer to trigger setParams + jest.advanceTimersByTime(PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + + // Should set params to select Perps tab + expect(mockNavigation.setParams).toHaveBeenCalledWith({ + initialTab: 'perps', + shouldSelectPerpsTab: true, + }); + }); + + it('should use goBack when not from deeplink', () => { + // Default params (not from deeplink) + (useRoute as jest.Mock).mockReturnValue({ + params: {}, + }); + + render(); + + // Press skip button + act(() => { + fireEvent.press(screen.getByText(strings('perps.tutorial.skip'))); + }); + + // Should use goBack instead of navigate + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('should handle undefined route params gracefully', () => { + // Mock route without params + (useRoute as jest.Mock).mockReturnValue({ + params: undefined, + }); + + render(); + + // Press skip button + act(() => { + fireEvent.press(screen.getByText(strings('perps.tutorial.skip'))); + }); + + // Should default to goBack behavior + expect(mockNavigation.goBack).toHaveBeenCalled(); + expect(mockNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('should handle deposit confirmation error gracefully', async () => { + // Mock deposit failure + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockDepositWithConfirmation.mockRejectedValue( + new Error('Deposit failed'), + ); + + render(); + + // Navigate to last screen and press Add funds + for (let i = 0; i < 5; i++) { + const continueButton = screen.getByText( + strings('perps.tutorial.continue'), + ); + fireEvent.press(continueButton); + } + + // Press Add funds button + fireEvent.press(screen.getByText(strings('perps.tutorial.add_funds'))); + + // The depositWithConfirmation is called asynchronously + // We need to wait for the next tick for the promise to reject + await Promise.resolve(); + + // Should log error + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to initialize deposit:', + expect.any(Error), + ); + + consoleErrorSpy.mockRestore(); + }); + }); }); diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx index c3940e757680..398d7afaae67 100644 --- a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx +++ b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx @@ -1,4 +1,9 @@ -import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { + NavigationProp, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; import React, { useCallback, useEffect, @@ -25,6 +30,7 @@ import { PerpsEventProperties, PerpsEventValues, } from '../../constants/eventNames'; +import { PERFORMANCE_CONFIG } from '../../constants/perpsConfig'; import type { PerpsNavigationParamList } from '../../controllers/types'; import { usePerpsFirstTimeUser, usePerpsTrading } from '../../hooks'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; @@ -91,9 +97,16 @@ const tutorialScreens: TutorialScreen[] = [ }, ]; +interface PerpsTutorialRouteParams { + isFromDeeplink?: boolean; +} + const PerpsTutorialCarousel: React.FC = () => { const { styles } = useStyles(createStyles, {}); const navigation = useNavigation>(); + const route = + useRoute>(); + const isFromDeeplink = route.params?.isFromDeeplink || false; const { markTutorialCompleted } = usePerpsFirstTimeUser(); const { track } = usePerpsEventTracking(); const { depositWithConfirmation } = usePerpsTrading(); @@ -156,6 +169,8 @@ const PerpsTutorialCarousel: React.FC = () => { markTutorialCompleted(); // Navigate immediately to confirmations screen for instant UI response + // Note: When from deeplink, user will go through deposit flow + // and should return to markets after completion navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS, }); @@ -203,8 +218,31 @@ const PerpsTutorialCarousel: React.FC = () => { // Mark tutorial as completed markTutorialCompleted(); } - navigation.goBack(); - }, [isLastScreen, markTutorialCompleted, navigation, currentTab, track]); + + // Navigate based on deeplink flag + if (isFromDeeplink) { + // Navigate to wallet home first + navigation.navigate(Routes.WALLET.HOME); + + // The timeout is REQUIRED - React Navigation needs time to complete + // the navigation before params can be set on the new screen + setTimeout(() => { + navigation.setParams({ + initialTab: 'perps', + shouldSelectPerpsTab: true, + }); + }, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + } else { + navigation.goBack(); + } + }, [ + isLastScreen, + markTutorialCompleted, + navigation, + currentTab, + track, + isFromDeeplink, + ]); const renderTabBar = () => ; diff --git a/app/components/UI/Perps/constants/perpsConfig.ts b/app/components/UI/Perps/constants/perpsConfig.ts index e9619d18ceb5..a4b0658d720d 100644 --- a/app/components/UI/Perps/constants/perpsConfig.ts +++ b/app/components/UI/Perps/constants/perpsConfig.ts @@ -69,6 +69,11 @@ export const PERFORMANCE_CONFIG = { // Prevents excessive validation calls during rapid form input changes VALIDATION_DEBOUNCE_MS: 1000, + // Navigation params delay (milliseconds) + // Required for React Navigation to complete state transitions before setting params + // This ensures navigation context is available when programmatically selecting tabs + NAVIGATION_PARAMS_DELAY_MS: 100, + // Market data cache duration (milliseconds) // How long to cache market list data before fetching fresh data MARKET_DATA_CACHE_DURATION_MS: 5 * 60 * 1000, // 5 minutes diff --git a/app/components/UI/Perps/controllers/PerpsController.test.ts b/app/components/UI/Perps/controllers/PerpsController.test.ts index 98c3a7702e81..0b92f9c8be52 100644 --- a/app/components/UI/Perps/controllers/PerpsController.test.ts +++ b/app/components/UI/Perps/controllers/PerpsController.test.ts @@ -2383,6 +2383,192 @@ describe('PerpsController', () => { }); }); + describe('resetFirstTimeUserState', () => { + it('should reset isFirstTimeUser to true for both networks', () => { + withController(({ controller }) => { + // Arrange - set both networks as not first-time + controller.state.isFirstTimeUser = { + testnet: false, + mainnet: false, + }; + controller.state.hasPlacedFirstOrder = { + testnet: true, + mainnet: true, + }; + + // Act + controller.resetFirstTimeUserState(); + + // Assert - both should be reset + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + }); + }); + + it('should reset from partially completed state', () => { + withController(({ controller }) => { + // Arrange - only testnet completed + controller.state.isFirstTimeUser = { + testnet: false, + mainnet: true, + }; + controller.state.hasPlacedFirstOrder = { + testnet: true, + mainnet: false, + }; + + // Act + controller.resetFirstTimeUserState(); + + // Assert - both should be reset to initial state + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + }); + }); + + it('should reset even when already in first-time state', () => { + withController(({ controller }) => { + // Arrange - already in first-time state + controller.state.isFirstTimeUser = { + testnet: true, + mainnet: true, + }; + controller.state.hasPlacedFirstOrder = { + testnet: false, + mainnet: false, + }; + + // Act + controller.resetFirstTimeUserState(); + + // Assert - should remain in first-time state + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + }); + }); + + it('should work correctly regardless of current network', async () => { + await withController( + async ({ controller }) => { + // Arrange - complete tutorial on testnet + controller.state.isTestnet = true; + controller.markTutorialCompleted(); + controller.markFirstOrderCompleted(); + + expect(controller.state.isFirstTimeUser.testnet).toBe(false); + expect(controller.state.hasPlacedFirstOrder.testnet).toBe(true); + + // Act - reset while on testnet + controller.resetFirstTimeUserState(); + + // Assert - both networks should be reset + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + + // Verify current network detection still works + expect(controller.isFirstTimeUserOnCurrentNetwork()).toBe(true); + }, + { state: { isTestnet: true } }, + ); + }); + + it('should persist reset state', async () => { + // First controller instance - simulate completed state then reset + await withController( + async ({ controller }) => { + // Assert initial completed state was loaded + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: false, + mainnet: false, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: true, + mainnet: true, + }); + + // Reset everything + controller.resetFirstTimeUserState(); + + // Assert reset worked + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + }, + { + state: { + isTestnet: false, + // Simulate previously completed state + isFirstTimeUser: { + testnet: false, + mainnet: false, + }, + hasPlacedFirstOrder: { + testnet: true, + mainnet: true, + }, + }, + }, + ); + + // Second controller instance - verify reset state persisted + await withController( + async ({ controller }) => { + // Assert - reset state should be persisted + expect(controller.state.isFirstTimeUser).toEqual({ + testnet: true, + mainnet: true, + }); + expect(controller.state.hasPlacedFirstOrder).toEqual({ + testnet: false, + mainnet: false, + }); + }, + { + // Use reset state to simulate persistence + state: { + isFirstTimeUser: { + testnet: true, + mainnet: true, + }, + hasPlacedFirstOrder: { + testnet: false, + mainnet: false, + }, + }, + }, + ); + }); + }); + describe('reconnectWithNewContext', () => { it('should clear state and reinitialize providers', async () => { // Mock initialize to succeed before creating controller diff --git a/app/components/UI/Perps/controllers/PerpsController.ts b/app/components/UI/Perps/controllers/PerpsController.ts index 60c9e2017fa8..340988d3ebe9 100644 --- a/app/components/UI/Perps/controllers/PerpsController.ts +++ b/app/components/UI/Perps/controllers/PerpsController.ts @@ -267,6 +267,10 @@ export type PerpsControllerActions = | { type: 'PerpsController:markFirstOrderCompleted'; handler: PerpsController['markFirstOrderCompleted']; + } + | { + type: 'PerpsController:resetFirstTimeUserState'; + handler: PerpsController['resetFirstTimeUserState']; }; /** @@ -1539,4 +1543,27 @@ export class PerpsController extends BaseController< state.hasPlacedFirstOrder[currentNetwork] = true; }); } + + /** + * Reset first-time user state for both networks + * This is useful for testing the tutorial flow + * Called by Reset Account feature in settings + */ + resetFirstTimeUserState(): void { + DevLogger.log('PerpsController: Resetting first-time user state', { + timestamp: new Date().toISOString(), + previousState: this.state.isFirstTimeUser, + }); + + this.update((state) => { + state.isFirstTimeUser = { + testnet: true, + mainnet: true, + }; + state.hasPlacedFirstOrder = { + testnet: false, + mainnet: false, + }; + }); + } } diff --git a/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.test.ts b/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.test.ts index 8a6b1f33ce79..57b0ae04485b 100644 --- a/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.test.ts @@ -15,6 +15,7 @@ jest.mock('../../../../core/Engine', () => ({ context: { PerpsController: { markTutorialCompleted: jest.fn(), + resetFirstTimeUserState: jest.fn(), }, }, })); @@ -45,6 +46,7 @@ describe('usePerpsFirstTimeUser', () => { expect(result.current).toEqual({ isFirstTimeUser: true, markTutorialCompleted: expect.any(Function), + resetFirstTimeUserState: expect.any(Function), }); }); @@ -65,6 +67,7 @@ describe('usePerpsFirstTimeUser', () => { expect(result.current).toEqual({ isFirstTimeUser: false, markTutorialCompleted: expect.any(Function), + resetFirstTimeUserState: expect.any(Function), }); }); @@ -86,6 +89,7 @@ describe('usePerpsFirstTimeUser', () => { expect(result.current).toEqual({ isFirstTimeUser: true, markTutorialCompleted: expect.any(Function), + resetFirstTimeUserState: expect.any(Function), }); }); @@ -108,7 +112,26 @@ describe('usePerpsFirstTimeUser', () => { expect(mockMarkTutorialCompleted).toHaveBeenCalledWith(); }); - it('should handle PerpsController being undefined gracefully', () => { + it('should call PerpsController.resetFirstTimeUserState when resetFirstTimeUserState is called', () => { + // Arrange + mockUsePerpsSelector.mockImplementation( + (selector: (state: PerpsControllerState) => T) => { + expect(selector).toBe(selectIsFirstTimeUser); + return false as T; + }, + ); + const mockResetFirstTimeUserState = Engine.context.PerpsController + .resetFirstTimeUserState as jest.Mock; + + // Act + const { result } = renderHook(() => usePerpsFirstTimeUser()); + result.current.resetFirstTimeUserState(); + + // Assert + expect(mockResetFirstTimeUserState).toHaveBeenCalledWith(); + }); + + it('should handle PerpsController being undefined gracefully for markTutorialCompleted', () => { // Arrange mockUsePerpsSelector.mockImplementation( (selector: (state: PerpsControllerState) => T) => { @@ -125,4 +148,22 @@ describe('usePerpsFirstTimeUser', () => { // Should not throw when markTutorialCompleted is called expect(() => result.current.markTutorialCompleted()).not.toThrow(); }); + + it('should handle PerpsController being undefined gracefully for resetFirstTimeUserState', () => { + // Arrange + mockUsePerpsSelector.mockImplementation( + (selector: (state: PerpsControllerState) => T) => { + expect(selector).toBe(selectIsFirstTimeUser); + return true as T; + }, + ); + // @ts-expect-error - Testing undefined case + Engine.context.PerpsController = undefined; + + // Act + const { result } = renderHook(() => usePerpsFirstTimeUser()); + + // Should not throw when resetFirstTimeUserState is called + expect(() => result.current.resetFirstTimeUserState()).not.toThrow(); + }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.ts b/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.ts index dc8e895ffc2c..bf2243578333 100644 --- a/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.ts +++ b/app/components/UI/Perps/hooks/usePerpsFirstTimeUser.ts @@ -4,11 +4,12 @@ import { usePerpsSelector } from './usePerpsSelector'; /** * Hook to check if the user is a first-time user of perps trading - * @returns Object with isFirstTimeUser flag and markTutorialCompleted function + * @returns Object with isFirstTimeUser flag, markTutorialCompleted function, and resetFirstTimeUserState function */ export function usePerpsFirstTimeUser(): { isFirstTimeUser: boolean; markTutorialCompleted: () => void; + resetFirstTimeUserState: () => void; } { const isFirstTimeUser = usePerpsSelector(selectIsFirstTimeUser); @@ -16,8 +17,13 @@ export function usePerpsFirstTimeUser(): { Engine.context.PerpsController?.markTutorialCompleted(); }; + const resetFirstTimeUserState = () => { + Engine.context.PerpsController?.resetFirstTimeUserState(); + }; + return { isFirstTimeUser, markTutorialCompleted, + resetFirstTimeUserState, }; } diff --git a/app/components/UI/Perps/selectors/perpsController/index.test.ts b/app/components/UI/Perps/selectors/perpsController/index.test.ts index 01c417ef5a0a..8f4a3ab9bb5e 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.test.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.test.ts @@ -6,6 +6,7 @@ import { selectPerpsDepositState, selectPerpsEligibility, selectPerpsNetwork, + selectIsFirstTimePerpsUser, } from './index'; describe('PerpsController Selectors', () => { @@ -614,4 +615,137 @@ describe('PerpsController Selectors', () => { expect(network).toBe('testnet'); }); }); + + describe('selectIsFirstTimePerpsUser', () => { + it('returns true when isFirstTimeUser is true for testnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + testnet: true, + mainnet: false, + }, + isTestnet: true, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + expect(result).toBe(true); + }); + + it('returns false when isFirstTimeUser is false for testnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + testnet: false, + mainnet: true, + }, + isTestnet: true, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + expect(result).toBe(false); + }); + + it('returns true when isFirstTimeUser is true for mainnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + testnet: false, + mainnet: true, + }, + isTestnet: false, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + expect(result).toBe(true); + }); + + it('returns false when isFirstTimeUser is false for mainnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + testnet: true, + mainnet: false, + }, + isTestnet: false, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + expect(result).toBe(false); + }); + + it('returns true when isFirstTimeUser is undefined (default state)', () => { + // Arrange + const mockState = createMockState({ + isTestnet: true, + // isFirstTimeUser is undefined + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + // Should return true for undefined state (first time user) + expect(result).toBe(true); + }); + + it('returns true when PerpsController state is undefined', () => { + // Arrange + const mockState = createMockState(undefined); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + // Should return true when no state exists (first time user) + expect(result).toBe(true); + }); + + it('handles missing network state gracefully for testnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + mainnet: false, + // testnet is undefined + }, + isTestnet: true, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + // Should return true for undefined testnet state + expect(result).toBe(true); + }); + + it('handles missing network state gracefully for mainnet', () => { + // Arrange + const mockState = createMockState({ + isFirstTimeUser: { + testnet: false, + // mainnet is undefined + }, + isTestnet: false, + }); + + // Act + const result = selectIsFirstTimePerpsUser(mockState); + + // Assert + // Should return true for undefined mainnet state + expect(result).toBe(true); + }); + }); }); diff --git a/app/components/UI/Perps/selectors/perpsController/index.ts b/app/components/UI/Perps/selectors/perpsController/index.ts index 147af0598a07..91b38271ff73 100644 --- a/app/components/UI/Perps/selectors/perpsController/index.ts +++ b/app/components/UI/Perps/selectors/perpsController/index.ts @@ -42,10 +42,19 @@ const selectPerpsNetwork = createSelector( perpsControllerState?.isTestnet ? 'testnet' : 'mainnet', ); +// Import the existing selector logic +import { selectIsFirstTimeUser } from '../../controllers/selectors'; + +const selectIsFirstTimePerpsUser = createSelector( + selectPerpsControllerState, + (perpsControllerState) => selectIsFirstTimeUser(perpsControllerState), +); + export { selectPerpsProvider, selectPerpsAccountState, selectPerpsDepositState, selectPerpsEligibility, selectPerpsNetwork, + selectIsFirstTimePerpsUser, }; diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 6442ef655675..c4433d3517d6 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -80,11 +80,17 @@ import { selectContractBalances } from '../../../selectors/tokenBalancesControll import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import AccountSelector from '../Ramp/Aggregator/components/AccountSelector'; import { QuoteViewSelectorIDs } from '../../../../e2e/selectors/swaps/QuoteView.selectors'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isRemoveGlobalNetworkSelectorEnabled, +} from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { getSwapsLiveness } from '../../../reducers/swaps/utils'; import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; import { useStablecoinsDefaultSlippage } from './useStablecoinsDefaultSlippage'; +import { selectNetworkImageSourceByChainId } from '../../../selectors/networkInfos'; +import ContextualNetworkPicker from '../ContextualNetworkPicker/ContextualNetworkPicker'; +import Routes from '../../../constants/navigation/Routes'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { useChainRedirect } from './useChainRedirect'; import Text, { @@ -203,6 +209,8 @@ function SwapsAmountView({ currentCurrency, setLiveness, shouldUseSmartTransaction, + networkName, + networkImageSource, }) { const accounts = accountsByChainId[chainId]; const navigation = useNavigation(); @@ -212,6 +220,7 @@ function SwapsAmountView({ const styles = createStyles(colors); const previousSelectedAddress = useRef(); + const previousChainId = useRef(); // Use the new hook for chain redirection ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) @@ -437,10 +446,13 @@ function SwapsAmountView({ }, [isTokenInBalances, selectedAddress, sourceToken]); /** - * Reset the state when account changes + * Reset the state when account or chain changes */ useEffect(() => { - if (selectedAddress !== previousSelectedAddress.current) { + if ( + selectedAddress !== previousSelectedAddress.current || + chainId !== previousChainId.current + ) { setAmount('0'); setSourceToken( swapsTokens?.find((token) => @@ -450,8 +462,9 @@ function SwapsAmountView({ setDestinationToken(null); setSlippage(AppConstants.SWAPS.DEFAULT_SLIPPAGE); previousSelectedAddress.current = selectedAddress; + previousChainId.current = chainId; } - }, [selectedAddress, swapsTokens, initialSource]); + }, [selectedAddress, swapsTokens, initialSource, chainId]); const hasInvalidDecimals = useMemo(() => { if (sourceToken) { @@ -688,6 +701,12 @@ function SwapsAmountView({ setDestinationToken(sourceToken); }, [destinationToken, sourceToken]); + const onNetworkSelectorPress = useCallback(() => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.NETWORK_SELECTOR, + }); + }, [navigation]); + const disabledView = !destinationTokenHasEnoughOcurrances && !hasDismissedTokenAlert; @@ -703,6 +722,13 @@ function SwapsAmountView({ contentContainerStyle={styles.screen} keyboardShouldPersistTaps="handled" > + {isRemoveGlobalNetworkSelectorEnabled() ? ( + + ) : null} @@ -1042,6 +1068,14 @@ SwapsAmountView.propTypes = { * Whether to use smart transactions */ shouldUseSmartTransaction: PropTypes.bool, + /** + * Network name + */ + networkName: PropTypes.string, + /** + * Network image source + */ + networkImageSource: PropTypes.string, }; const mapStateToProps = (state) => ({ @@ -1062,6 +1096,13 @@ const mapStateToProps = (state) => ({ state, selectEvmChainId(state), ), + networkName: + selectEvmNetworkConfigurationsByChainId(state)?.[selectEvmChainId(state)] + ?.name || '', + networkImageSource: selectNetworkImageSourceByChainId( + state, + selectEvmChainId(state), + ), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index 4301f087bc29..24c095e6457d 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -210,7 +210,7 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { {isMultichainAccountsState2Enabled && selectedAccountGroup ? ( ) : ( diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx new file mode 100644 index 000000000000..b435c3273a87 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx @@ -0,0 +1,191 @@ +/* eslint-disable react-native/no-inline-styles */ +/* eslint-disable react/display-name */ +// Third party dependencies +import React from 'react'; +import { AccountGroupObject } from '@metamask/account-tree-controller'; + +// External dependencies +import { mockTheme } from '../../../../util/theme'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Box } from '@metamask/design-system-react-native'; +import Text, { + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { withNavigation } from '../../../../../.storybook/decorators'; + +// Internal dependencies +import MultichainAccountsConnectedList from './MultichainAccountsConnectedList'; +import { createMockAccountGroup } from '../../../../component-library/components-temp/MultichainAccounts/test-utils'; + +// No mock store needed - component only uses props + +// Mock account groups for different scenarios +const createMockAccountGroups = (count: number): AccountGroupObject[] => { + const groups: AccountGroupObject[] = []; + + for (let i = 1; i <= count; i++) { + groups.push( + createMockAccountGroup(`group-${i}`, `Account ${i}`, [`account-${i}`]), + ); + } + + return groups; +}; + +// Simple wrapper component for consistent styling +const StoryContainer = ({ children }: { children: React.ReactNode }) => { + const tw = useTailwind(); + + const WrappedStory = () => ( + + {children} + + ); + + return withNavigation(WrappedStory); +}; + +// Sample account groups +const NO_ACCOUNTS: AccountGroupObject[] = []; +const ONE_ACCOUNT = createMockAccountGroups(1); +const THREE_ACCOUNTS = createMockAccountGroups(3); + +// Default props +const defaultProps = { + privacyMode: false, + handleEditAccountsButtonPress: () => { + // Handle edit accounts button press + }, +}; + +const InteractiveStoryContainer = ({ + privacyMode, + accountCount, +}: { + privacyMode: boolean; + accountCount: number; +}) => { + const tw = useTailwind(); + const accountGroups = createMockAccountGroups(accountCount); + + const WrappedStory = () => ( + + + Interactive Demo + + + Use the controls below to adjust the component properties. + + + + ); + + return withNavigation(WrappedStory); +}; + +const MultichainAccountsConnectedListMeta = { + title: 'Component Library / Views / MultichainAccountsConnectedList', + component: MultichainAccountsConnectedList, + argTypes: { + privacyMode: { + control: { type: 'boolean' }, + description: 'Whether privacy mode is enabled', + }, + selectedAccountGroups: { + control: { type: 'object' }, + description: 'Array of selected account groups', + }, + }, +}; + +export default MultichainAccountsConnectedListMeta; + +// Individual stories for each scenario +export const NoAccounts = { + render: () => ( + + + + ), +}; + +export const OneAccount = { + render: () => ( + + + + ), +}; + +export const ThreeAccounts = { + render: () => ( + + + + ), +}; + +export const WithPrivacyMode = { + render: () => ( + + + + ), +}; + +// Interactive story with controls +export const Interactive = { + render: (args: { privacyMode: boolean; accountCount: number }) => ( + + ), + args: { + privacyMode: false, + accountCount: 3, + }, + argTypes: { + privacyMode: { + control: { type: 'boolean' }, + description: 'Enable or disable privacy mode', + }, + accountCount: { + control: { + type: 'range', + min: 0, + max: 10, + step: 1, + }, + description: 'Number of account groups to display', + }, + }, +}; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts new file mode 100644 index 000000000000..fddac55d8e6a --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts @@ -0,0 +1,68 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../../util/theme/models'; +import { + ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT, + MAX_VISIBLE_ITEMS, +} from '../../../UI/PermissionsSummary/PermissionSummary.constants'; + +interface MultichainAccountsConnectedListStyleSheetVars { + itemHeight: number; + numOfAccounts: number; +} + +/** + * Style sheet function for MultichainAccountsConnectedList screen. + * @returns StyleSheet object. + */ +const styleSheet = (params: { + theme: Theme; + vars: MultichainAccountsConnectedListStyleSheetVars; +}) => { + const { theme } = params; + const { colors } = theme; + const { numOfAccounts } = params.vars; + + return StyleSheet.create({ + // Account List Item + container: { + maxHeight: ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT * MAX_VISIBLE_ITEMS, + }, + accountListItem: { + borderWidth: 0, + height: ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT, + justifyContent: 'center', + }, + accountsConnectedContainer: { + backgroundColor: colors.background.default, + marginTop: 8, + overflow: 'hidden', + minHeight: ACCOUNTS_CONNECTED_LIST_ITEM_HEIGHT * numOfAccounts, + }, + // Balances Container + balancesContainer: { + alignItems: 'flex-end', + flexDirection: 'column', + }, + balanceLabel: { + textAlign: 'right', + }, + // Edit Accounts + editAccountsContainer: { + marginTop: 8, + marginLeft: 16, + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + gap: 8, + }, + editAccountIcon: { + borderRadius: 8, + backgroundColor: colors.info.muted, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx new file mode 100644 index 000000000000..4cddacd5c285 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { render, fireEvent } from '@testing-library/react-native'; +import { AccountGroupObject } from '@metamask/account-tree-controller'; + +import { ConnectedAccountsSelectorsIDs } from '../../../../../e2e/selectors/Browser/ConnectedAccountModal.selectors'; + +import MultichainAccountsConnectedList from './MultichainAccountsConnectedList'; +import { createMockAccountGroup } from '../../../../component-library/components-temp/MultichainAccounts/test-utils'; + +jest.mock('../../../../selectors/assets/balances', () => { + const actual = jest.requireActual('../../../../selectors/assets/balances'); + return { + ...actual, + selectBalanceByAccountGroup: (groupId: string) => () => ({ + walletId: groupId.split('/')[0], + groupId, + totalBalanceInUserCurrency: 0, + userCurrency: 'usd', + }), + }; +}); + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + +const MOCK_ACCOUNT_GROUP_1 = createMockAccountGroup('group-1', 'Account 1', [ + 'account-1', +]); + +const MOCK_ACCOUNT_GROUP_2 = createMockAccountGroup('group-2', 'Account 2', [ + 'account-2', +]); + +const MOCK_ACCOUNT_GROUP_3 = createMockAccountGroup( + 'group-3', + 'Multichain Account', + ['account-3a', 'account-3b'], +); + +const MOCK_SELECTED_ACCOUNT_GROUPS: AccountGroupObject[] = [ + MOCK_ACCOUNT_GROUP_1, + MOCK_ACCOUNT_GROUP_2, +]; + +const MOCK_MULTICHAIN_ACCOUNT_GROUPS: AccountGroupObject[] = [ + MOCK_ACCOUNT_GROUP_1, + MOCK_ACCOUNT_GROUP_2, + MOCK_ACCOUNT_GROUP_3, +]; +const DEFAULT_PROPS = { + privacyMode: false, + selectedAccountGroups: MOCK_SELECTED_ACCOUNT_GROUPS, + handleEditAccountsButtonPress: jest.fn(), +}; + +const mockStore = configureStore([]); +const mockInitialState = { + settings: { + useBlockieIcon: false, + }, + engine: { + backgroundState: { + AccountTreeController: { + accountTree: { + wallets: {}, + }, + }, + }, + }, +}; + +const renderMultichainAccountsConnectedList = (propOverrides = {}) => { + const props = { ...DEFAULT_PROPS, ...propOverrides }; + const store = mockStore(mockInitialState); + return render( + + + , + ); +}; + +const renderWithMultipleAccounts = () => + renderMultichainAccountsConnectedList({ + selectedAccountGroups: MOCK_MULTICHAIN_ACCOUNT_GROUPS, + }); + +const renderWithEmptyAccountGroups = () => + renderMultichainAccountsConnectedList({ + selectedAccountGroups: [], + }); + +describe('MultichainAccountsConnectedList', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Component Rendering', () => { + it('renders the component with account groups', () => { + const { getByText } = renderMultichainAccountsConnectedList(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('renders with empty account groups list', () => { + const { getByText } = renderWithEmptyAccountGroups(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('renders with multiple account groups', () => { + const { getByText } = renderWithMultipleAccounts(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + }); + + it('renders component with selected account groups', () => { + const { getByText } = renderMultichainAccountsConnectedList(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('calls handleEditAccountsButtonPress when edit button is pressed', () => { + const mockHandleEdit = jest.fn(); + const { getByText } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: mockHandleEdit, + }); + + const editButton = getByText('Edit accounts'); + fireEvent.press(editButton); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + }); + + it('has correct testID for edit accounts button', () => { + const { getByTestId } = renderMultichainAccountsConnectedList(); + + const editButton = getByTestId( + ConnectedAccountsSelectorsIDs.ACCOUNT_LIST_BOTTOM_SHEET, + ); + expect(editButton).toBeTruthy(); + }); + + it('displays correct edit accounts text', () => { + const { getByText } = renderMultichainAccountsConnectedList(); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('handles empty selectedAccountGroups', () => { + const { getByText } = renderMultichainAccountsConnectedList({ + selectedAccountGroups: [], + }); + + expect(getByText('Edit accounts')).toBeTruthy(); + }); + + it('handles null handleEditAccountsButtonPress', () => { + const { getByText } = renderMultichainAccountsConnectedList({ + handleEditAccountsButtonPress: null, + }); + + const editButton = getByText('Edit accounts'); + + expect(() => fireEvent.press(editButton)).not.toThrow(); + }); +}); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx new file mode 100644 index 000000000000..fceb32c636b7 --- /dev/null +++ b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx @@ -0,0 +1,84 @@ +// Third party dependencies. +import React, { useCallback } from 'react'; +import { View, TouchableOpacity } from 'react-native'; + +// external dependencies +import { strings } from '../../../../../locales/i18n'; +import { useStyles } from '../../../../component-library/hooks'; +import TextComponent, { + TextColor, + TextVariant, +} from '../../../../component-library/components/Texts/Text'; +import { ConnectedAccountsSelectorsIDs } from '../../../../../e2e/selectors/Browser/ConnectedAccountModal.selectors'; + +// internal dependencies +import styleSheet from './MultichainAccountsConnectedList.styles'; +import { AccountGroupObject } from '@metamask/account-tree-controller'; +import { FlashList } from '@shopify/flash-list'; +import AccountListCell from '../../../../component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListCell'; +import Avatar, { + AvatarVariant, +} from '../../../../component-library/components/Avatars/Avatar'; +import { IconName } from '../../../../component-library/components/Icons/Icon'; + +const MultichainAccountsConnectedList = ({ + privacyMode, + selectedAccountGroups, + handleEditAccountsButtonPress, +}: { + privacyMode: boolean; + selectedAccountGroups: AccountGroupObject[]; + handleEditAccountsButtonPress: () => void; +}) => { + const { styles } = useStyles(styleSheet, { + itemHeight: 64, + numOfAccounts: selectedAccountGroups.length, + }); + + const renderItem = useCallback( + ({ item }: { item: AccountGroupObject }) => ( + { + // No op here because it is handled by edit accounts. + }} + // @ts-expect-error - This is temporary because the account list cell is being updated in another PR. + privacyMode={privacyMode} + /> + ), + [privacyMode], + ); + + return ( + + + + + + + + {strings('accounts.edit_accounts_title')} + + + + ); +}; + +export default MultichainAccountsConnectedList; diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index fa1e217fb533..74b752510c79 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import { fireEvent, waitFor } from '@testing-library/react-native'; -import { useRoute } from '@react-navigation/native'; // External dependencies import renderWithProvider from '../../../util/test/renderWithProvider'; @@ -15,7 +14,6 @@ import { CHAIN_IDS } from '@metamask/transaction-controller'; import { NetworkListModalSelectorsIDs } from '../../../../e2e/selectors/Network/NetworkListModal.selectors'; import { isNetworkUiRedesignEnabled } from '../../../util/networks/isNetworkUiRedesignEnabled'; import { mockNetworkState } from '../../../util/test/network'; -import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; jest.mock('../../../util/metrics/MultichainAPI/networkMetricUtils', () => ({ removeItemFromChainIdList: jest.fn().mockReturnValue({ @@ -45,12 +43,6 @@ jest.mock('../../../core/Analytics', () => ({ }, })); -const mockedUseRoute = jest.mocked(useRoute); -const mockSelectSendFlowContextualChainId = jest.fn(); -jest.mock('../../../selectors/sendFlow', () => ({ - selectSendFlowContextualChainId: mockSelectSendFlowContextualChainId, -})); - // eslint-disable-next-line import/no-namespace import * as selectedNetworkControllerFcts from '../../../selectors/selectedNetworkController'; // eslint-disable-next-line import/no-namespace @@ -83,16 +75,9 @@ jest.mock('@react-navigation/native', () => { navigate: mockedNavigate, goBack: mockedGoBack, }), - useRoute: jest.fn(() => ({ - params: {}, - })), }; }); -jest.mock('../../../selectors/sendFlow', () => ({ - selectSendFlowContextualChainId: jest.fn(), -})); - jest.mock('../../../core/Engine', () => ({ getTotalEvmFiatAccountBalance: jest.fn(), context: { @@ -731,176 +716,4 @@ describe('Network Selector', () => { }); }); }); - - describe('contextual chain ID logic', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('uses contextual chain ID when source is SendFlow and contextual chain ID exists', () => { - const contextualChainId = '0x89'; // Polygon - - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: NETWORK_SELECTOR_SOURCES.SEND_FLOW, - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(contextualChainId); - - const { getByText } = renderComponent(initialState); - - // The Polygon network should be selected when contextual chain ID is Polygon - const polygonCell = getByText('Polygon Mainnet'); - expect(polygonCell).toBeTruthy(); - }); - - it('does not use contextual chain ID when source is not SendFlow', () => { - const contextualChainId = '0x89'; // Polygon - - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: 'other-source', - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(contextualChainId); - - const { getByText } = renderComponent(initialState); - - // Should fall back to per-dapp chain ID instead of contextual - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('does not use contextual chain ID when source is SendFlow but no contextual chain ID exists', () => { - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: NETWORK_SELECTOR_SOURCES.SEND_FLOW, - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(null); - - const { getByText } = renderComponent(initialState); - - // Should fall back to per-dapp chain ID when no contextual chain ID - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('does not use contextual chain ID when source is SendFlow but contextual chain ID is undefined', () => { - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: NETWORK_SELECTOR_SOURCES.SEND_FLOW, - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(undefined); - - const { getByText } = renderComponent(initialState); - - // Should fall back to per-dapp chain ID when contextual chain ID is undefined - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('uses contextual chain ID for different networks when source is SendFlow', () => { - const contextualChainId = '0xa86a'; // Avalanche - - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: NETWORK_SELECTOR_SOURCES.SEND_FLOW, - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(contextualChainId); - - const { getByText } = renderComponent(initialState); - - // The Avalanche network should be available when contextual chain ID is Avalanche - const avalancheCell = getByText('Avalanche Mainnet C-Chain'); - expect(avalancheCell).toBeTruthy(); - }); - - it('handles missing route params gracefully', () => { - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: undefined, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue('0x89'); - - const { getByText } = renderComponent(initialState); - - // Should not crash and should fall back to per-dapp chain ID - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('handles missing source param gracefully', () => { - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - // source is missing - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue('0x89'); - - const { getByText } = renderComponent(initialState); - - // Should not crash and should fall back to per-dapp chain ID - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('verifies isContextualChainId is false when conditions are not met', () => { - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: 'other-source', - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(null); - - const { getByText } = renderComponent(initialState); - - // Should use per-dapp chain ID (mainnet in this case) - const ethereumCell = getByText('Ethereum Mainnet'); - expect(ethereumCell).toBeTruthy(); - }); - - it('verifies isContextualChainId is true when both conditions are met', () => { - const contextualChainId = '0x64'; - mockedUseRoute.mockImplementation(() => ({ - key: 'mock-key', - name: 'mock-route', - params: { - source: NETWORK_SELECTOR_SOURCES.SEND_FLOW, - }, - })); - - mockSelectSendFlowContextualChainId.mockReturnValue(contextualChainId); - - const { getByText } = renderComponent(initialState); - - const gnosisCell = getByText('Gnosis Chain'); - expect(gnosisCell).toBeTruthy(); - }); - }); }); diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index e830dc571b02..70ec119795e9 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -102,8 +102,10 @@ import { MultichainNetworkConfiguration } from '@metamask/multichain-network-con import { useSwitchNetworks } from './useSwitchNetworks'; import { removeItemFromChainIdList } from '../../../util/metrics/MultichainAPI/networkMetricUtils'; import { MetaMetrics } from '../../../core/Analytics'; -import { selectSendFlowContextualChainId } from '../../../selectors/sendFlow'; -import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; +import { + NETWORK_SELECTOR_SOURCES, + NetworkSelectorSource, +} from '../../../constants/networkSelector'; interface infuraNetwork { name: string; @@ -124,7 +126,7 @@ interface NetworkSelectorRouteParams { origin?: string; }; }; - source?: string; + source?: NetworkSelectorSource; } const NetworkSelector = () => { @@ -158,7 +160,6 @@ const NetworkSelector = () => { ///: END:ONLY_INCLUDE_IF const isEvmSelected = useSelector(selectIsEvmNetworkSelected); - const contextualChainId = useSelector(selectSendFlowContextualChainId); const route = useRoute, string>>(); @@ -171,20 +172,15 @@ const NetworkSelector = () => { tags: getTraceTags(store.getState()), op: TraceOperation.NetworkSwitch, }); - const { - chainId: perDappChainId, + chainId: selectedChainId, rpcUrl: selectedRpcUrl, domainIsConnectedDapp, networkName: selectedNetworkName, } = useNetworkInfo(origin); - const isContextualChainId = - route.params?.source === NETWORK_SELECTOR_SOURCES.SEND_FLOW && - contextualChainId; - const selectedChainId = isContextualChainId - ? contextualChainId - : perDappChainId; + const isSendFlow = + route.params?.source === NETWORK_SELECTOR_SOURCES.SEND_FLOW; const avatarSize = isNetworkUiRedesignEnabled() ? AvatarSize.Sm : undefined; const modalTitle = isNetworkUiRedesignEnabled() @@ -232,8 +228,6 @@ const NetworkSelector = () => { const deleteModalSheetRef = useRef(null); - const source = route.params?.source; - /** * This is used to check if the network has multiple RPC endpoints * We need to check if the network is non-EVM because we don't support multiple RPC endpoints for non-EVM networks and the rpc is handled by the snap @@ -382,7 +376,6 @@ const NetworkSelector = () => { dismissModal: () => sheetRef.current?.dismissModal(), closeRpcModal, parentSpan, - source, }); useEffect(() => { @@ -901,7 +894,7 @@ const NetworkSelector = () => { {renderRpcNetworks()} { ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - !isContextualChainId && renderNonEvmNetworks(false) + !isSendFlow && renderNonEvmNetworks(false) ///: END:ONLY_INCLUDE_IF } {isNetworkUiRedesignEnabled() && @@ -910,7 +903,7 @@ const NetworkSelector = () => { {isNetworkUiRedesignEnabled() && renderAdditonalNetworks()} {searchString.length === 0 && renderTestNetworksSwitch()} {showTestNetworks && renderOtherNetworks()} - {showTestNetworks && renderNonEvmNetworks(true)} + {!isSendFlow && showTestNetworks && renderNonEvmNetworks(true)} ); diff --git a/app/components/Views/NetworkSelector/useSwitchNetworks.ts b/app/components/Views/NetworkSelector/useSwitchNetworks.ts index 2cd14c4a2765..3f80e36b1aa8 100644 --- a/app/components/Views/NetworkSelector/useSwitchNetworks.ts +++ b/app/components/Views/NetworkSelector/useSwitchNetworks.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import Engine from '../../../core/Engine'; import { getDecimalChainId, @@ -44,8 +44,6 @@ import { NetworkType, useNetworksByNamespace, } from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { setTransactionSendFlowContextualChainId } from '../../../actions/sendFlow'; -import { NETWORK_SELECTOR_SOURCES } from '../../../constants/networkSelector'; interface UseSwitchNetworksProps { domainIsConnectedDapp?: boolean; @@ -55,7 +53,6 @@ interface UseSwitchNetworksProps { dismissModal?: () => void; closeRpcModal?: () => void; parentSpan?: unknown; - source?: string; } interface UseSwitchNetworksReturn { @@ -81,7 +78,6 @@ export function useSwitchNetworks({ dismissModal, closeRpcModal, parentSpan, - source, }: UseSwitchNetworksProps): UseSwitchNetworksReturn { const isAllNetwork = useSelector(selectIsAllNetworks); const networkConfigurations = useSelector( @@ -94,7 +90,6 @@ export function useSwitchNetworks({ const { selectNetwork } = useNetworkSelection({ networks, }); - const dispatch = useDispatch(); ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const isSolanaAccountAlreadyCreated = useSelector( @@ -155,11 +150,7 @@ export function useSwitchNetworks({ }); const { networkClientId } = rpcEndpoints[defaultRpcEndpointIndex]; try { - if (source === NETWORK_SELECTOR_SOURCES.SEND_FLOW) { - dispatch(setTransactionSendFlowContextualChainId(chainId)); - } else { - await MultichainNetworkController.setActiveNetwork(networkClientId); - } + await MultichainNetworkController.setActiveNetwork(networkClientId); } catch (error) { Logger.error(new Error(`Error in setActiveNetwork: ${error}`)); } @@ -189,8 +180,6 @@ export function useSwitchNetworks({ createEventBuilder, parentSpan, dismissModal, - source, - dispatch, ], ); @@ -224,16 +213,8 @@ export function useSwitchNetworks({ networkConfiguration.defaultRpcEndpointIndex ].networkClientId ?? type; - if (source !== NETWORK_SELECTOR_SOURCES.SEND_FLOW) { - setTokenNetworkFilter(networkConfiguration.chainId); - await MultichainNetworkController.setActiveNetwork(clientId); - } else { - dispatch( - setTransactionSendFlowContextualChainId( - networkConfiguration.chainId, - ), - ); - } + setTokenNetworkFilter(networkConfiguration.chainId); + await MultichainNetworkController.setActiveNetwork(clientId); closeRpcModal?.(); AccountTrackerController.refresh([clientId]); @@ -270,8 +251,6 @@ export function useSwitchNetworks({ parentSpan, dismissModal, closeRpcModal, - dispatch, - source, ], ); diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index 6508abf12639..e27d075dddd1 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -547,15 +547,6 @@ class Onboarding extends PureComponent { handleLoginError = (error, socialConnectionType) => { if (error instanceof OAuthError) { // For OAuth API failures (excluding user cancellation/dismissal), handle based on analytics consent - if ( - error.code !== OAuthErrorType.UserCancelled && - error.code !== OAuthErrorType.UserDismissed && - error.code !== OAuthErrorType.GoogleLoginError && - error.code !== OAuthErrorType.AppleLoginError - ) { - this.handleOAuthLoginError(error); - return; - } if ( error.code === OAuthErrorType.UserCancelled || error.code === OAuthErrorType.UserDismissed || @@ -564,7 +555,27 @@ class Onboarding extends PureComponent { ) { // QA: do not show error sheet if user cancelled return; + } else if ( + error.code === OAuthErrorType.GoogleLoginNoCredential || + error.code === OAuthErrorType.GoogleLoginNoMatchingCredential + ) { + // de-escalate google no credential error + const errorMessage = 'google_login_no_credential'; + this.props.navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + title: strings(`error_sheet.${errorMessage}_title`), + description: strings(`error_sheet.${errorMessage}_description`), + descriptionAlign: 'center', + buttonLabel: strings(`error_sheet.${errorMessage}_button`), + type: 'error', + }, + }); + return; } + // unexpected oauth login error + this.handleOAuthLoginError(error); + return; } const errorMessage = 'oauth_error'; diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index 1204e0fbbd99..a9364240cc3e 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -22,7 +22,7 @@ import { Authentication } from '../../../core'; import Routes from '../../../constants/navigation/Routes'; import { ONBOARDING, PREVIOUS_SCREEN } from '../../../constants/navigation'; import { strings } from '../../../../locales/i18n'; -import Logger from '../../../util/Logger'; +import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; const mockInitialState = { engine: { @@ -73,28 +73,6 @@ jest.mock('../../../core/OAuthService/OAuthService', () => ({ resetOauthState: jest.fn(), })); -jest.mock('../../../core/OAuthService/error', () => ({ - OAuthError: class OAuthError extends Error { - code: string; - constructor(code: string) { - super(); - this.code = code; - } - }, - OAuthErrorType: { - UnknownError: 'unknown_error', - UserCancelled: 'user_cancelled', - UserDismissed: 'user_dismissed', - LoginError: 'login_error', - InvalidProvider: 'invalid_provider', - UnsupportedPlatform: 'unsupported_platform', - LoginInProgress: 'login_in_progress', - AuthServerError: 'auth_server_error', - InvalidGetAuthTokenParams: 'invalid_get_auth_token_params', - InvalidOauthStateError: 'invalid_oauth_state_error', - }, -})); - jest.mock('../../../store/storage-wrapper', () => ({ getItem: jest.fn(), })); @@ -569,9 +547,6 @@ describe('Onboarding', () => { const mockCreateLoginHandler = jest.requireMock( '../../../core/OAuthService/OAuthLoginHandlers', ).createLoginHandler; - const { OAuthError, OAuthErrorType } = jest.requireMock( - '../../../core/OAuthService/error', - ); beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); @@ -682,7 +657,7 @@ describe('Onboarding', () => { }); it('should show error sheet for OAuth user cancellation', async () => { - const cancelError = new OAuthError(OAuthErrorType.UserCancelled); + const cancelError = new OAuthError('', OAuthErrorType.UserCancelled); mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); mockOAuthService.handleOAuthLogin.mockRejectedValue(cancelError); @@ -729,7 +704,7 @@ describe('Onboarding', () => { }); it('do not show error sheet for OAuth user dismissal', async () => { - const dismissError = new OAuthError(OAuthErrorType.UserDismissed); + const dismissError = new OAuthError('', OAuthErrorType.UserDismissed); mockCreateLoginHandler.mockReturnValue('mockAppleHandler'); mockOAuthService.handleOAuthLogin.mockRejectedValue(dismissError); @@ -864,6 +839,118 @@ describe('Onboarding', () => { }), ); }); + + it('should show error sheet for OAuth when no credential is available in Android', async () => { + const noCredentialError = new OAuthError( + '', + OAuthErrorType.GoogleLoginNoCredential, + ); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockRejectedValue(noCredentialError); + + mockNavigate.mockClear(); + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + mockNavigate.mockClear(); + await act(async () => { + await googleOAuthFunction(true); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + title: strings('error_sheet.google_login_no_credential_title'), + description: strings( + 'error_sheet.google_login_no_credential_description', + ), + descriptionAlign: 'center', + buttonLabel: strings( + 'error_sheet.google_login_no_credential_button', + ), + type: 'error', + }), + }), + ); + }); + + it('should show error sheet for OAuth when no matching credential in Android', async () => { + const noCredentialError = new OAuthError( + '', + OAuthErrorType.GoogleLoginNoMatchingCredential, + ); + mockCreateLoginHandler.mockReturnValue('mockGoogleHandler'); + mockOAuthService.handleOAuthLogin.mockRejectedValue(noCredentialError); + + mockNavigate.mockClear(); + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle; + + mockNavigate.mockClear(); + await act(async () => { + await googleOAuthFunction(true); + }); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MODAL.ROOT_MODAL_FLOW, + expect.objectContaining({ + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: expect.objectContaining({ + title: strings('error_sheet.google_login_no_credential_title'), + description: strings( + 'error_sheet.google_login_no_credential_description', + ), + descriptionAlign: 'center', + buttonLabel: strings( + 'error_sheet.google_login_no_credential_button', + ), + type: 'error', + }), + }), + ); + }); }); describe('ErrorBoundary Tests', () => { @@ -873,9 +960,6 @@ describe('Onboarding', () => { const mockCreateLoginHandler = jest.requireMock( '../../../core/OAuthService/OAuthLoginHandlers', ).createLoginHandler; - const { OAuthError, OAuthErrorType } = jest.requireMock( - '../../../core/OAuthService/error', - ); beforeEach(() => { mockSeedlessOnboardingEnabled.mockReturnValue(true); @@ -888,12 +972,12 @@ describe('Onboarding', () => { }); it('should trigger ErrorBoundary for OAuth login failures when analytics disabled', async () => { - const loggerErrorSpy = jest.spyOn(Logger, 'error'); mockMetricsIsEnabled.mockReturnValueOnce(false); - const dismissError = new OAuthError(OAuthErrorType.AuthServerError); + const serverError = new OAuthError('', OAuthErrorType.AuthServerError); mockCreateLoginHandler.mockReturnValue('mockAppleHandler'); - mockOAuthService.handleOAuthLogin.mockRejectedValue(dismissError); + mockOAuthService.handleOAuthLogin.mockRejectedValue(serverError); + mockNavigate.mockClear(); const { getByTestId } = renderScreen( Onboarding, { name: 'Onboarding' }, @@ -921,16 +1005,6 @@ describe('Onboarding', () => { await appleOAuthFunction(false); }); - // Verify that the built-in ErrorBoundary caught the error and rendered its fallback UI - expect(loggerErrorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'OAuth login failed: ', - }), - expect.objectContaining({ - View: 'Onboarding', - ErrorBoundary: true, - }), - ); expect(mockTrackEvent).toHaveBeenLastCalledWith( expect.objectContaining({ name: 'Error Screen Viewed', @@ -940,7 +1014,7 @@ describe('Onboarding', () => { it('should not trigger ErrorBoundary for OAuth login failures when analytics enabled', async () => { mockMetricsIsEnabled.mockReturnValue(true); - const dismissError = new OAuthError(OAuthErrorType.AuthServerError); + const dismissError = new OAuthError('', OAuthErrorType.AuthServerError); mockCreateLoginHandler.mockReturnValue('mockAppleHandler'); mockOAuthService.handleOAuthLogin.mockRejectedValue(dismissError); diff --git a/app/components/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx b/app/components/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx index fabbd9405bdc..5f526719b9d1 100644 --- a/app/components/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx +++ b/app/components/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx @@ -12,6 +12,7 @@ import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; import { selectChainId } from '../../../../../selectors/networkController'; +import { usePerpsFirstTimeUser } from '../../../../UI/Perps/hooks/usePerpsFirstTimeUser'; export const ResetAccountModal = ({ resetModalVisible, @@ -28,6 +29,7 @@ export const ResetAccountModal = ({ selectSelectedInternalAccountFormattedAddress, ); const chainId = useSelector(selectChainId); + const { resetFirstTimeUserState } = usePerpsFirstTimeUser(); const resetAccount = () => { if (selectedAddress) { @@ -35,6 +37,8 @@ export const ResetAccountModal = ({ wipeSmartTransactions(selectedAddress); } wipeTransactions(); + // Reset Perps first-time user state for testing + resetFirstTimeUserState(); navigation.navigate('WalletView'); }; diff --git a/app/components/Views/Wallet/index.test.tsx b/app/components/Views/Wallet/index.test.tsx index a8682facea0f..c98e71596003 100644 --- a/app/components/Views/Wallet/index.test.tsx +++ b/app/components/Views/Wallet/index.test.tsx @@ -368,10 +368,14 @@ jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); return { ...actualReactNavigation, - useNavigation: () => ({ + useNavigation: jest.fn(() => ({ navigate: mockNavigate, setOptions: mockSetOptions, - }), + })), + useRoute: jest.fn(() => ({ + params: {}, + })), + useFocusEffect: jest.fn(), }; }); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 7a35e92f72e1..f5bbd5e7112f 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -1,106 +1,114 @@ +import type { Theme } from '@metamask/design-tokens'; import React, { - useEffect, - useRef, useCallback, useContext, + useEffect, useMemo, + useRef, + useState, } from 'react'; import { ActivityIndicator, - StyleSheet as RNStyleSheet, - View, Linking, + StyleSheet as RNStyleSheet, TextStyle, + View, } from 'react-native'; -import type { Theme } from '@metamask/design-tokens'; -import { connect, useSelector, useDispatch } from 'react-redux'; import ScrollableTabView, { ChangeTabProperties, } from 'react-native-scrollable-tab-view'; -import { baseStyles } from '../../../styles/common'; -import Tokens from '../../UI/Tokens'; -import { getWalletNavbarOptions } from '../../UI/Navbar'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { strings } from '../../../../locales/i18n'; import TabBar from '../../../component-library/components-temp/TabBar'; +import { SOLANA_FEATURE_MODAL_SHOWN } from '../../../constants/storage'; +import { CONSENSYS_PRIVACY_POLICY } from '../../../constants/urls'; import { isPastPrivacyPolicyDate, shouldShowNewPrivacyToastSelector, - storePrivacyPolicyShownDate as storePrivacyPolicyShownDateAction, storePrivacyPolicyClickedOrClosed as storePrivacyPolicyClickedOrClosedAction, + storePrivacyPolicyShownDate as storePrivacyPolicyShownDateAction, } from '../../../reducers/legalNotices'; -import { CONSENSYS_PRIVACY_POLICY } from '../../../constants/urls'; import StorageWrapper from '../../../store/storage-wrapper'; -import { SOLANA_FEATURE_MODAL_SHOWN } from '../../../constants/storage'; +import { baseStyles } from '../../../styles/common'; +import { getWalletNavbarOptions } from '../../UI/Navbar'; +import Tokens from '../../UI/Tokens'; +import { + NavigationProp, + ParamListBase, + RouteProp, + useFocusEffect, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; +import { BannerAlertSeverity } from '../../../component-library/components/Banners/Banner'; +import BannerAlert from '../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; +import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; +import CustomText, { + TextColor, +} from '../../../component-library/components/Texts/Text'; import { ToastContext, ToastVariants, } from '../../../component-library/components/Toast'; -import NotificationsService from '../../../util/notifications/services/NotificationService'; -import Engine from '../../../core/Engine'; -import CollectibleContracts from '../../UI/CollectibleContracts'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import ErrorBoundary from '../ErrorBoundary'; -import { useTheme } from '../../../util/theme'; +import { useMetrics } from '../../../components/hooks/useMetrics'; import Routes from '../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import Engine from '../../../core/Engine'; +import { RootState } from '../../../reducers'; import { - getDecimalChainId, - getIsNetworkOnboarded, - isPortfolioViewEnabled, - isTestNet, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; + hideNftFetchingLoadingIndicator as hideNftFetchingLoadingIndicatorAction, + showNftFetchingLoadingIndicator as showNftFetchingLoadingIndicatorAction, +} from '../../../reducers/collectibles'; +import { + selectSelectedInternalAccount, + selectSelectedInternalAccountFormattedAddress, +} from '../../../selectors/accountsController'; +import { selectAccountBalanceByChainId } from '../../../selectors/accountTrackerController'; +import { selectIsBackupAndSyncEnabled } from '../../../selectors/identity'; import { selectChainId, selectEvmNetworkConfigurationsByChainId, selectIsAllNetworks, selectIsPopularNetwork, + selectNativeCurrencyByChainId, selectNetworkClientId, selectNetworkConfigurations, selectProviderConfig, - selectNativeCurrencyByChainId, } from '../../../selectors/networkController'; import { - selectNetworkName, selectNetworkImageSource, + selectNetworkName, } from '../../../selectors/networkInfos'; +import { + getMetamaskNotificationsReadCount, + getMetamaskNotificationsUnreadCount, + selectIsMetamaskNotificationsEnabled, +} from '../../../selectors/notifications'; import { selectAllDetectedTokensFlat, selectDetectedTokens, } from '../../../selectors/tokensController'; import { - NavigationProp, - ParamListBase, - useNavigation, -} from '@react-navigation/native'; -import BannerAlert from '../../../component-library/components/Banners/Banner/variants/BannerAlert/BannerAlert'; -import { BannerAlertSeverity } from '../../../component-library/components/Banners/Banner'; -import CustomText, { - TextColor, -} from '../../../component-library/components/Texts/Text'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { RootState } from '../../../reducers'; -import usePrevious from '../../hooks/usePrevious'; -import { - selectSelectedInternalAccount, - selectSelectedInternalAccountFormattedAddress, -} from '../../../selectors/accountsController'; -import { selectAccountBalanceByChainId } from '../../../selectors/accountTrackerController'; -import { - hideNftFetchingLoadingIndicator as hideNftFetchingLoadingIndicatorAction, - showNftFetchingLoadingIndicator as showNftFetchingLoadingIndicatorAction, -} from '../../../reducers/collectibles'; -import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; -import { - getMetamaskNotificationsUnreadCount, - getMetamaskNotificationsReadCount, - selectIsMetamaskNotificationsEnabled, -} from '../../../selectors/notifications'; -import { selectIsBackupAndSyncEnabled } from '../../../selectors/identity'; -import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; -import { useAccountName } from '../../hooks/useAccountName'; + getDecimalChainId, + getIsNetworkOnboarded, + isPortfolioViewEnabled, + isRemoveGlobalNetworkSelectorEnabled, + isTestNet, +} from '../../../util/networks'; +import NotificationsService from '../../../util/notifications/services/NotificationService'; +import { useTheme } from '../../../util/theme'; import { useAccountGroupName } from '../../hooks/multichainAccounts/useAccountGroupName'; +import { useAccountName } from '../../hooks/useAccountName'; +import usePrevious from '../../hooks/usePrevious'; +import CollectibleContracts from '../../UI/CollectibleContracts'; +import { PERFORMANCE_CONFIG } from '../../UI/Perps/constants/perpsConfig'; +import ErrorBoundary from '../ErrorBoundary'; +import { Nft, Token } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import AccountGroupBalance from '../../UI/Assets/components/Balance/AccountGroupBalance'; @@ -111,62 +119,59 @@ import { selectTokenNetworkFilter, selectUseTokenDetection, } from '../../../selectors/preferencesController'; -import { TokenI } from '../../UI/Tokens/types'; -import { Hex } from '@metamask/utils'; -import { Nft, Token } from '@metamask/assets-controllers'; -import { Carousel } from '../../UI/Carousel'; -import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; -import { useNftDetectionChainIds } from '../../hooks/useNftDetectionChainIds'; import Logger from '../../../util/Logger'; -import { DevLogger } from '../../../core/SDKConnect/utils/DevLogger'; +import { useNftDetectionChainIds } from '../../hooks/useNftDetectionChainIds'; +import { Carousel } from '../../UI/Carousel'; +import { TokenI } from '../../UI/Tokens/types'; + import { cloneDeep } from 'lodash'; -import { prepareNftDetectionEvents } from '../../../util/assets'; -import DeFiPositionsList from '../../UI/DeFiPositions/DeFiPositionsList'; import { selectAssetsDefiPositionsEnabled } from '../../../selectors/featureFlagController/assetsDefiPositions'; -import { toFormattedAddress } from '../../../util/address'; import { selectHDKeyrings } from '../../../selectors/keyringController'; +import { toFormattedAddress } from '../../../util/address'; +import { prepareNftDetectionEvents } from '../../../util/assets'; import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalyticsMetaData/UserProfileAnalyticsMetaData.types'; import { endTrace, trace, TraceName } from '../../../util/trace'; -import AssetDetailsActions from '../AssetDetails/AssetDetailsActions'; import { - useSwapBridgeNavigation, SwapBridgeNavigationLocation, + useSwapBridgeNavigation, } from '../../UI/Bridge/hooks/useSwapBridgeNavigation'; +import DeFiPositionsList from '../../UI/DeFiPositions/DeFiPositionsList'; +import AssetDetailsActions from '../AssetDetails/AssetDetailsActions'; import { QRTabSwitcherScreens } from '../QRTabSwitcher'; -import { newAssetTransaction } from '../../../actions/transaction'; -import { getEther } from '../../../util/transactions'; import { swapsUtils } from '@metamask/swaps-controller'; -import { isSwapsAllowed } from '../../UI/Swaps/utils'; -import { isBridgeAllowed } from '../../UI/Bridge/utils'; +import { newAssetTransaction } from '../../../actions/transaction'; import AppConstants from '../../../core/AppConstants'; -import useRampNetwork from '../../UI/Ramp/Aggregator/hooks/useRampNetwork'; import { selectIsSwapsLive, selectIsUnifiedSwapsEnabled, } from '../../../core/redux/slices/bridge'; +import { getEther } from '../../../util/transactions'; +import { isBridgeAllowed } from '../../UI/Bridge/utils'; +import useRampNetwork from '../../UI/Ramp/Aggregator/hooks/useRampNetwork'; +import { isSwapsAllowed } from '../../UI/Swaps/utils'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { useSendNonEvmAsset } from '../../hooks/useSendNonEvmAsset'; ///: END:ONLY_INCLUDE_IF -import { selectPerpsEnabledFlag } from '../../UI/Perps'; -import PerpsTabView from '../../UI/Perps/Views/PerpsTabView'; -import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; -import { useNetworkSelection } from '../../hooks/useNetworkSelection/useNetworkSelection'; -import { - useNetworksByNamespace, - NetworkType, -} from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { selectIsCardholder } from '../../../core/redux/slices/card'; -import { selectIsConnectionRemoved } from '../../../reducers/user'; +import { setIsConnectionRemoved } from '../../../actions/user'; import { IconColor, IconName, } from '../../../component-library/components/Icons/Icon'; -import { setIsConnectionRemoved } from '../../../actions/user'; +import { selectIsCardholder } from '../../../core/redux/slices/card'; +import { selectIsConnectionRemoved } from '../../../reducers/user'; +import { selectSolanaOnboardingModalEnabled } from '../../../selectors/multichain/multichain'; +import { selectEVMEnabledNetworks } from '../../../selectors/networkEnablementController'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; +import { + NetworkType, + useNetworksByNamespace, +} from '../../hooks/useNetworksByNamespace/useNetworksByNamespace'; +import { useNetworkSelection } from '../../hooks/useNetworkSelection/useNetworkSelection'; +import { selectPerpsEnabledFlag } from '../../UI/Perps'; +import PerpsTabView from '../../UI/Perps/Views/PerpsTabView'; import { InitSendLocation } from '../confirmations/constants/send'; import { useSendNavigation } from '../confirmations/hooks/useSendNavigation'; -import { selectSolanaOnboardingModalEnabled } from '../../../selectors/multichain/multichain'; const createStyles = ({ colors }: Theme) => RNStyleSheet.create({ @@ -214,142 +219,182 @@ interface WalletProps { hideNftFetchingLoadingIndicator: () => void; } -const WalletTokensTabView = React.memo( - (props: { - navigation: WalletProps['navigation']; - onChangeTab: (value: ChangeTabProperties) => void; - defiEnabled: boolean; - collectiblesEnabled: boolean; - }) => { - const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); - const { navigation, onChangeTab, defiEnabled, collectiblesEnabled } = props; - const [currentTabIndex, setCurrentTabIndex] = React.useState(0); - - const theme = useTheme(); - const styles = useMemo(() => createStyles(theme), [theme]); - - const renderTabBar = useCallback( - (tabBarProps: Record) => ( - - ), - [styles, theme], - ); +interface WalletTokensTabViewProps { + navigation: WalletProps['navigation']; + onChangeTab: (value: ChangeTabProperties) => void; + defiEnabled: boolean; + collectiblesEnabled: boolean; + navigationParams?: { + shouldSelectPerpsTab?: boolean; + initialTab?: string; + }; +} - const tokensTabProps = useMemo( - () => ({ - key: 'tokens-tab', - tabLabel: strings('wallet.tokens'), - navigation, - }), - [navigation], - ); +const WalletTokensTabView = React.memo((props: WalletTokensTabViewProps) => { + const isPerpsEnabled = useSelector(selectPerpsEnabledFlag); + const { + navigation, + onChangeTab, + defiEnabled, + collectiblesEnabled, + navigationParams, + } = props; + const route = useRoute>(); + // Type augmentation needed as @types/react-native-scrollable-tab-view doesn't expose goToPage method + const scrollableTabViewRef = useRef< + ScrollableTabView & { goToPage: (pageNumber: number) => void } + >(null); - const perpsTabProps = useMemo( - () => ({ - key: 'perps-tab', - tabLabel: strings('wallet.perps'), - navigation, - }), - [navigation], - ); + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); - const defiPositionsTabProps = useMemo( - () => ({ - key: 'defi-tab', - tabLabel: strings('wallet.defi'), - navigation, - }), - [navigation], - ); + // Track current tab index for Perps visibility + const [currentTabIndex, setCurrentTabIndex] = useState(0); + + const renderTabBar = useCallback( + (tabBarProps: Record) => ( + + ), + [styles, theme], + ); - const collectibleContractsTabProps = useMemo( - () => ({ - key: 'nfts-tab', - tabLabel: strings('wallet.collectibles'), - navigation, - }), - [navigation], - ); + const tokensTabProps = useMemo( + () => ({ + key: 'tokens-tab', + tabLabel: strings('wallet.tokens'), + navigation, + }), + [navigation], + ); - const handleTabChange = useCallback( - (changeTabProperties: ChangeTabProperties) => { - const newIndex = changeTabProperties.i; - const tabLabel = changeTabProperties.ref?.props?.tabLabel; - DevLogger.log('WalletTabView: Tab changed', { - newIndex, - tabLabel, - isPerpsTab: tabLabel === strings('wallet.perps'), - previousIndex: currentTabIndex, - }); - setCurrentTabIndex(newIndex); - onChangeTab(changeTabProperties); - }, - [onChangeTab, currentTabIndex], - ); + const perpsTabProps = useMemo( + () => ({ + key: 'perps-tab', + tabLabel: strings('wallet.perps'), + navigation, + }), + [navigation], + ); - // Calculate Perps tab index dynamically based on what tabs are enabled - // Tokens is always index 0, Perps is index 1 if enabled - const perpsTabIndex = isPerpsEnabled ? 1 : -1; - const isPerpsTabVisible = currentTabIndex === perpsTabIndex; + const defiPositionsTabProps = useMemo( + () => ({ + key: 'defi-tab', + tabLabel: strings('wallet.defi'), + navigation, + }), + [navigation], + ); - // Store the visibility update callback from PerpsTabView - const perpsVisibilityCallback = useRef<((visible: boolean) => void) | null>( - null, - ); + const collectibleContractsTabProps = useMemo( + () => ({ + key: 'nfts-tab', + tabLabel: strings('wallet.collectibles'), + navigation, + }), + [navigation], + ); - // Update Perps visibility when tab changes - useEffect(() => { - if (isPerpsEnabled && perpsVisibilityCallback.current) { - DevLogger.log('WalletTabView: Updating Perps visibility', { - currentTabIndex, - perpsTabIndex, - isPerpsTabVisible, - }); - perpsVisibilityCallback.current(isPerpsTabVisible); + // Handle tab changes and track current index + const handleTabChange = useCallback( + (changeTabProperties: ChangeTabProperties) => { + setCurrentTabIndex(changeTabProperties.i); + onChangeTab(changeTabProperties); + }, + [onChangeTab], + ); + + // Calculate Perps tab visibility + const perpsTabIndex = isPerpsEnabled ? 1 : -1; + const isPerpsTabVisible = currentTabIndex === perpsTabIndex; + + // Store the visibility update callback from PerpsTabView + const perpsVisibilityCallback = useRef<((visible: boolean) => void) | null>( + null, + ); + + // Update Perps visibility when tab changes + useEffect(() => { + if (isPerpsEnabled && perpsVisibilityCallback.current) { + perpsVisibilityCallback.current(isPerpsTabVisible); + } + }, [currentTabIndex, perpsTabIndex, isPerpsTabVisible, isPerpsEnabled]); + + // Handle tab selection from navigation params (e.g., from deeplinks) + // This uses useFocusEffect to ensure the tab selection happens when the screen receives focus + useFocusEffect( + useCallback(() => { + // Check both navigationParams prop and route params for tab selection + // Type assertion needed as route params are not strongly typed in navigation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params = navigationParams || (route.params as any); + const shouldSelectPerpsTab = params?.shouldSelectPerpsTab; + const initialTab = params?.initialTab; + + if ((shouldSelectPerpsTab || initialTab === 'perps') && isPerpsEnabled) { + // Calculate the index of the Perps tab + // Tokens is always at index 0, Perps is at index 1 when enabled + const targetPerpsTabIndex = 1; + + // Small delay ensures the ScrollableTabView is fully rendered before selection + const timer = setTimeout(() => { + scrollableTabViewRef.current?.goToPage(targetPerpsTabIndex); + + // Clear the params to prevent re-selection on subsequent focuses + // This is important for navigation state management + if (navigation?.setParams) { + navigation.setParams({ + shouldSelectPerpsTab: false, + initialTab: undefined, + }); + } + }, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + + return () => clearTimeout(timer); } - }, [currentTabIndex, perpsTabIndex, isPerpsTabVisible, isPerpsEnabled]); - - return ( - - - - {isPerpsEnabled && ( - { - perpsVisibilityCallback.current = callback; - }} - /> - )} - {defiEnabled && ( - - )} - {collectiblesEnabled && ( - - )} - - - ); - }, -); + }, [route.params, isPerpsEnabled, navigationParams, navigation]), + ); + + return ( + + + + {isPerpsEnabled && ( + { + perpsVisibilityCallback.current = callback; + }} + /> + )} + {defiEnabled && ( + + )} + {collectiblesEnabled && ( + + )} + + + ); +}); /** * Main view for the wallet @@ -363,8 +408,10 @@ const Wallet = ({ hideNftFetchingLoadingIndicator, }: WalletProps) => { const { navigate } = useNavigation(); + const route = useRoute>(); const walletRef = useRef(null); const theme = useTheme(); + const { toastRef } = useContext(ToastContext); const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); const styles = useMemo(() => createStyles(theme), [theme]); @@ -1059,6 +1106,7 @@ const Wallet = ({ onChangeTab={onChangeTab} defiEnabled={defiEnabled} collectiblesEnabled={isEvmSelected} + navigationParams={route.params} /> @@ -1082,6 +1130,7 @@ const Wallet = ({ swapsIsLive, onReceive, onSend, + route.params, ], ); const renderLoader = useCallback( diff --git a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap index 72ebd00da5bd..c806e085ab35 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap @@ -447,7 +447,7 @@ exports[`Amount converts ERC-20 token value to USD 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -1151,7 +1151,7 @@ exports[`Amount converts ETH to USD 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -1855,7 +1855,7 @@ exports[`Amount converts USD to ERC-20 token value 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -2576,7 +2576,7 @@ exports[`Amount converts USD to ETH 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -3297,7 +3297,7 @@ exports[`Amount displays correct balance 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -4000,7 +4000,7 @@ exports[`Amount does not show a warning when conversion rate is available 1`] = style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -4924,7 +4924,7 @@ exports[`Amount does not show a warning when transfering collectibles 1`] = ` `; -exports[`Amount isRemoveGlobalNetworkSelectorEnabled contextual network selector enabled shows ContextualNetworkPicker when flag is enabled 1`] = ` +exports[`Amount proceeds if balance is sufficient while on Native primary currency is ETH 1`] = ` - - - - - - - - - - Avalanche - - - - - - - - - 󰋼 - - - - - Fiat conversions are not available at this moment - - - - Collectible + ETH + + + + + $1.00 + + + + 󰓢 + + + + + - Balance: 0 + Balance: 5 ETH @@ -5656,8 +5553,9 @@ exports[`Amount isRemoveGlobalNetworkSelectorEnabled contextual network selector @@ -5731,7 +5623,7 @@ exports[`Amount isRemoveGlobalNetworkSelectorEnabled contextual network selector `; -exports[`Amount proceeds if balance is sufficient while on Native primary currency is ETH 1`] = ` +exports[`Amount proceeds if balance is sufficient while on Native primary currency is not ETH 1`] = ` - ETH + AVAX - Balance: 5 ETH + Balance: 5 AVAX @@ -6430,706 +6322,7 @@ exports[`Amount proceeds if balance is sufficient while on Native primary curren `; -exports[`Amount proceeds if balance is sufficient while on Native primary currency is not ETH 1`] = ` - - - - - - - - - - - - - Amount - - - - - - - - - - - - - - - - - - - - - - - - - - AVAX - - - -  - - - - - - - - Use max - - - - - - - - - - - - - - - $1.00 - - - - 󰓢 - - - - - - - - Balance: 5 AVAX - - - - - - - - - - - Next - - - - - - - - - - - - - - - - -`; - -exports[`Amount renders correctly 1`] = ` +exports[`Amount renders correctly 1`] = ` @@ -7651,7 +6845,7 @@ exports[`Amount renders correctly 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", @@ -7718,7 +6912,7 @@ exports[`Amount renders correctly 1`] = ` } } > - Balance: 0 + Balance: 0 undefined @@ -8220,7 +7414,8 @@ exports[`Amount shows a warning when conversion rate is not available 1`] = ` style={ { "lineHeight": 20, - "paddingLeft": 4, + "paddingLeft": 10, + "paddingRight": 10, } } > @@ -8348,7 +7543,7 @@ exports[`Amount shows a warning when conversion rate is not available 1`] = ` style={ { "alignSelf": "flex-end", - "color": "#686e7d", + "color": "#4459ff", "fontFamily": "CentraNo1-Book", "fontSize": 12, "fontWeight": "400", diff --git a/app/components/Views/confirmations/legacy/SendFlow/Amount/index.js b/app/components/Views/confirmations/legacy/SendFlow/Amount/index.js index 7b4d36881369..b08e01921fbf 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Amount/index.js +++ b/app/components/Views/confirmations/legacy/SendFlow/Amount/index.js @@ -79,19 +79,9 @@ import { selectConversionRateByChainId, selectCurrentCurrency, } from '../../../../../../selectors/currencyRateController'; -import { - selectTokens, - selectAllTokens, -} from '../../../../../../selectors/tokensController'; -import { - selectAccounts, - selectAccountsByContextualChainId, -} from '../../../../../../selectors/accountTrackerController'; -import { - selectContractBalances, - selectAllTokenBalances, - selectContractBalancesByContextualChainId, -} from '../../../../../../selectors/tokenBalancesController'; +import { selectTokens } from '../../../../../../selectors/tokensController'; +import { selectAccounts } from '../../../../../../selectors/accountTrackerController'; +import { selectContractBalances } from '../../../../../../selectors/tokenBalancesController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../../../../selectors/accountsController'; import Routes from '../../../../../../constants/navigation/Routes'; import { getRampNetworks } from '../../../../../../reducers/fiatOrders'; @@ -113,18 +103,11 @@ import { /* eslint-enable no-restricted-syntax */ selectNativeCurrencyByChainId, selectProviderTypeByChainId, - selectNetworkConfigurationByChainId, - selectNetworkConfigurations, } from '../../../../../../selectors/networkController'; import { selectContractExchangeRatesByChainId } from '../../../../../../selectors/tokenRatesController'; import { isNativeToken } from '../../../utils/generic'; import { selectConfirmationRedesignFlags } from '../../../../../../selectors/featureFlagController/confirmations'; import { MMM_ORIGIN } from '../../../constants/confirmations'; -import { selectSendFlowContextualChainId } from '../../../../../../selectors/sendFlow'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; -import { setTransactionSendFlowContextualChainId } from '../../../../../../actions/sendFlow'; -import { selectNetworkImageSourceByChainId } from '../../../../../../selectors/networkInfos'; -import ContextualNetworkPicker from '../../../../../UI/ContextualNetworkPicker/ContextualNetworkPicker'; import { selectIsSwapsLive } from '../../../../../../core/redux/slices/bridge'; const KEYBOARD_OFFSET = Device.isSmallDevice() ? 80 : 120; @@ -187,13 +170,6 @@ const createStyles = (colors) => alignSelf: 'flex-end', textTransform: 'uppercase', }, - maxTextDisabled: { - ...fontStyles.normal, - fontSize: 12, - color: colors.text.alternative, - alignSelf: 'flex-end', - textTransform: 'uppercase', - }, actionMax: { flexDirection: 'row', alignItems: 'center', @@ -373,7 +349,8 @@ const createStyles = (colors) => }, warningTextContainer: { lineHeight: 20, - paddingLeft: 4, + paddingLeft: 10, + paddingRight: 10, }, warningText: { lineHeight: 20, @@ -522,53 +499,13 @@ class Amount extends PureComponent { * Boolean that indicates if the redesigned transfer confirmation is enabled */ isRedesignedTransferConfirmationEnabledForTransfer: PropTypes.bool, - /** - * Object containing token balances in the format address => balance by contextual chain id - */ - contractBalancesByContextualChainId: PropTypes.object, - /** - * Send flow contextual chain id - */ - contextualChainId: PropTypes.string, - /** - * All token balances - */ - allTokenBalances: PropTypes.object, - /** - * Accounts by contextual chain id - */ - accountsByContextualChainId: PropTypes.object, - /** - * Send flow contextual network configuration - */ - contextualNetworkConfiguration: PropTypes.object, - /** - * Object containing token exchange rates in the format address => exchangeRate by contextual chain id - */ - contractExchangeRatesByContextualChainId: PropTypes.object, - /** - * Current provider ticker by contextual chain id - */ - tickerByContextualChainId: PropTypes.string, - /** - * All tokens by chain id - */ - allTokensByChainId: PropTypes.object, - /** - * Network name - */ - networkName: PropTypes.string, - /** - * Network image source - */ - networkImageSource: PropTypes.object, }; state = { amountError: undefined, inputValue: undefined, inputValueConversion: undefined, - displayableInputValueConversion: undefined, + renderableInputValueConversion: undefined, assetsModalVisible: false, internalPrimaryCurrencyIsCrypto: this.props.primaryCurrency === 'ETH', estimatedTotalGas: undefined, @@ -590,7 +527,6 @@ class Amount extends PureComponent { route, colors, resetTransaction, - null, ), ); }; @@ -606,30 +542,12 @@ class Amount extends PureComponent { isPaymentRequest, gasEstimateType, gasFeeEstimates, - allTokensByChainId, - selectedAddress, - contextualChainId, - tickerByContextualChainId, } = this.props; // For analytics this.updateNavBar(); - navigation.setParams({ - providerType, - isPaymentRequest, - }); - const allTokensByChainIdAndAddress = - allTokensByChainId?.[contextualChainId]?.[ - selectedAddress?.toLowerCase() - ] ?? []; - const tokensToRender = isRemoveGlobalNetworkSelectorEnabled() - ? allTokensByChainIdAndAddress - : tokens; - const currentTicker = isRemoveGlobalNetworkSelectorEnabled() - ? tickerByContextualChainId - : ticker; - - this.tokens = [getEther(currentTicker), ...tokensToRender]; + navigation.setParams({ providerType, isPaymentRequest }); + this.tokens = [getEther(ticker), ...tokens]; this.collectibles = this.processCollectibles(); // Wait until navigation finishes to focus InteractionManager.runAfterInteractions(() => @@ -693,31 +611,20 @@ class Amount extends PureComponent { }; hasExchangeRate = () => { - const { - selectedAsset, - conversionRate, - contractExchangeRates, - contractExchangeRatesByContextualChainId, - } = this.props; + const { selectedAsset, conversionRate, contractExchangeRates } = this.props; + if (isNativeToken(selectedAsset)) { return !!conversionRate; } - const globallySelectedExchangeRate = + const exchangeRate = contractExchangeRates?.[selectedAsset.address]?.price ?? null; - const contextuallySelectedExchangeRate = - contractExchangeRatesByContextualChainId?.[selectedAsset.address] - ?.price ?? null; - const exchangeRate = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedExchangeRate - : globallySelectedExchangeRate; - return !!exchangeRate; }; /** * Method to validate collectible ownership. * - * @returns Promise that resolves ownership as a boolean. + * @returns Promise that resolves ownershio as a boolean. */ validateCollectibleOwnership = async () => { const { NftController } = Engine.context; @@ -744,13 +651,12 @@ class Amount extends PureComponent { const { navigation, selectedAsset, + setSelectedAsset, transactionState: { transaction }, providerType, onConfirm, globalNetworkClientId, isRedesignedTransferConfirmationEnabledForTransfer, - contextualNetworkConfiguration, - conversionRate, } = this.props; const { inputValue, @@ -766,7 +672,10 @@ class Amount extends PureComponent { value = inputValueConversion; if (maxFiatInput) { value = `${renderFromWei( - fiatNumberToWei(handleWeiNumber(maxFiatInput), conversionRate), + fiatNumberToWei( + handleWeiNumber(maxFiatInput), + this.props.conversionRate, + ), 18, )}`; } @@ -804,6 +713,7 @@ class Amount extends PureComponent { const shouldUseRedesignedTransferConfirmation = isRedesignedTransferConfirmationEnabledForTransfer; + setSelectedAsset(selectedAsset); if (onConfirm) { onConfirm(); } else if (shouldUseRedesignedTransferConfirmation) { @@ -819,20 +729,9 @@ class Amount extends PureComponent { : BNToHex(transaction.value), }; - const { rpcEndpoints, defaultRpcEndpointIndex } = - contextualNetworkConfiguration; - const { networkClientId: sendFlowContextualNetworkClientId } = - rpcEndpoints[defaultRpcEndpointIndex]; - - const effectiveNetworkClientId = - sendFlowContextualNetworkClientId || globalNetworkClientId; - const currentNetworkClientId = isRemoveGlobalNetworkSelectorEnabled() - ? effectiveNetworkClientId - : globalNetworkClientId; - await addTransaction(transactionParams, { origin: MMM_ORIGIN, - networkClientId: currentNetworkClientId, + networkClientId: globalNetworkClientId, }); this.setState({ isRedesignedTransferTransactionLoading: false }); navigation.navigate('SendFlowView', { @@ -843,7 +742,7 @@ class Amount extends PureComponent { } }; - getCollectibleTransferTransactionProperties() { + getCollectibleTranferTransactionProperties() { const { selectedAsset, transactionState: { transaction, transactionTo }, @@ -897,7 +796,7 @@ class Amount extends PureComponent { transaction.value = BNToHex(toWei(value)); } else if (selectedAsset.tokenId) { const collectibleTransferTransactionProperties = - this.getCollectibleTransferTransactionProperties(); + this.getCollectibleTranferTransactionProperties(); transaction.data = collectibleTransferTransactionProperties.data; transaction.to = collectibleTransferTransactionProperties.to; transaction.value = collectibleTransferTransactionProperties.value; @@ -920,15 +819,8 @@ class Amount extends PureComponent { * @returns - Whether there is an error with the amount */ validateAmount = (inputValue, internalPrimaryCurrencyIsCrypto) => { - const { - accounts, - selectedAddress, - selectedAsset, - contractBalances, - allTokenBalances, - accountsByContextualChainId, - contextualChainId, - } = this.props; + const { accounts, selectedAddress, selectedAsset, contractBalances } = + this.props; const { estimatedTotalGas, inputValueConversion } = this.state; let value = inputValue; @@ -936,13 +828,6 @@ class Amount extends PureComponent { value = inputValueConversion; } - const contextuallySelectedAccount = - accountsByContextualChainId?.[selectedAddress]; - const contextuallySelectedTokenBalance = - allTokenBalances?.[selectedAddress?.toLowerCase()]?.[contextualChainId]?.[ - selectedAsset.address - ]; - let weiBalance, weiInput, amountError; if (isDecimal(value)) { // toWei can throw error if input is not a number: Error: while converting number to string, invalid number value @@ -959,25 +844,10 @@ class Amount extends PureComponent { if (!amountError) { if (isNativeToken(selectedAsset)) { - const globallySelectedBalance = - accounts?.[selectedAddress]?.balance || '0x0'; - const contextuallySelectedNativeBalance = hexToBN( - contextuallySelectedAccount?.balance || '0x0', - ); - const balance = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedNativeBalance - : hexToBN(globallySelectedBalance); - - weiBalance = balance; + weiBalance = hexToBN(accounts[selectedAddress].balance); weiInput = weiValue.add(estimatedTotalGas); } else { - const globallySelectedBalance = - contractBalances?.[selectedAsset.address] || '0x0'; - const balance = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedTokenBalance || '0x0' - : globallySelectedBalance; - - weiBalance = hexToBN(balance); + weiBalance = hexToBN(contractBalances[selectedAsset.address]); weiInput = toTokenMinimalUnit(value, selectedAsset.decimals); } // TODO: weiBalance is not always guaranteed to be type BN. Need to consolidate type. @@ -999,35 +869,20 @@ class Amount extends PureComponent { * Estimate transaction gas with information available */ estimateGasLimit = async () => { - const { - globalNetworkClientId, - transactionState, - contextualNetworkConfiguration, - } = this.props; const { transaction: { from }, transactionTo, - } = transactionState; - - const { rpcEndpoints, defaultRpcEndpointIndex } = - contextualNetworkConfiguration; - const { networkClientId: sendFlowContextualNetworkClientId } = - rpcEndpoints[defaultRpcEndpointIndex]; - const effectiveNetworkClientId = - sendFlowContextualNetworkClientId || globalNetworkClientId; - - const currentNetworkClientId = isRemoveGlobalNetworkSelectorEnabled() - ? effectiveNetworkClientId - : globalNetworkClientId; - + } = this.props.transactionState; + const { globalNetworkClientId } = this.props; const { gas } = await getGasLimit( { from, to: transactionTo, }, false, - currentNetworkClientId, + globalNetworkClientId, ); + return gas; }; @@ -1039,32 +894,17 @@ class Amount extends PureComponent { selectedAsset, conversionRate, contractExchangeRates, - contractBalancesByContextualChainId, - contractExchangeRatesByContextualChainId, - accountsByContextualChainId, } = this.props; const { internalPrimaryCurrencyIsCrypto, estimatedTotalGas } = this.state; - const contextuallySelectedBalance = - contractBalancesByContextualChainId?.[selectedAsset.address]; - const globallySelectedBalance = - contractBalances?.[selectedAsset.address] || '0x0'; - const tokenBalance = - isRemoveGlobalNetworkSelectorEnabled() && contextuallySelectedBalance - ? contextuallySelectedBalance - : globallySelectedBalance; - + const tokenBalance = contractBalances[selectedAsset.address] || '0x0'; let input; if (isNativeToken(selectedAsset)) { - const currentBalance = isRemoveGlobalNetworkSelectorEnabled() - ? accountsByContextualChainId?.[selectedAddress]?.balance || '0x0' - : accounts?.[selectedAddress]?.balance || '0x0'; - const balanceBN = hexToBN(currentBalance); + const balanceBN = hexToBN(accounts[selectedAddress].balance); const realMaxValue = balanceBN.sub(estimatedTotalGas); const maxValue = balanceBN.isZero() || realMaxValue.isNeg() ? hexToBN('0x0') : realMaxValue; - if (internalPrimaryCurrencyIsCrypto) { input = fromWei(maxValue); } else { @@ -1074,15 +914,9 @@ class Amount extends PureComponent { }); } } else { - const globallySelectedExchangeRate = - contractExchangeRates?.[selectedAsset.address]?.price ?? null; - const contextuallySelectedExchangeRate = - contractExchangeRatesByContextualChainId?.[selectedAsset.address] - ?.price ?? null; - const exchangeRate = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedExchangeRate - : globallySelectedExchangeRate; - + const exchangeRate = contractExchangeRates + ? contractExchangeRates[selectedAsset.address]?.price + : undefined; if (internalPrimaryCurrencyIsCrypto || !exchangeRate) { input = fromTokenMinimalUnitString( tokenBalance, @@ -1106,14 +940,13 @@ class Amount extends PureComponent { currentCurrency, ticker, setMaxValueMode, - contractExchangeRatesByContextualChainId, } = this.props; const { internalPrimaryCurrencyIsCrypto } = this.state; setMaxValueMode(useMax ?? false); let inputValueConversion, - displayableInputValueConversion, + renderableInputValueConversion, hasExchangeRate, comma; // Remove spaces from input @@ -1139,10 +972,9 @@ class Amount extends PureComponent { } hasExchangeRate = !!conversionRate; - if (internalPrimaryCurrencyIsCrypto) { inputValueConversion = `${weiToFiatNumber(weiValue, conversionRate)}`; - displayableInputValueConversion = `${weiToFiat( + renderableInputValueConversion = `${weiToFiat( weiValue, conversionRate, currentCurrency, @@ -1151,29 +983,23 @@ class Amount extends PureComponent { inputValueConversion = `${renderFromWei( fiatNumberToWei(processedInputValue, conversionRate), )}`; - displayableInputValueConversion = `${inputValueConversion} ${processedTicker}`; + renderableInputValueConversion = `${inputValueConversion} ${processedTicker}`; } } else { - const globallySelectedExchangeRate = - contractExchangeRates?.[selectedAsset.address]?.price ?? null; - const contextuallySelectedExchangeRate = - contractExchangeRatesByContextualChainId?.[selectedAsset.address] - ?.price ?? null; - const exchangeRatePrice = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedExchangeRate - : globallySelectedExchangeRate; - - hasExchangeRate = !!exchangeRatePrice; + const exchangeRate = contractExchangeRates + ? contractExchangeRates[selectedAsset.address]?.price + : null; + hasExchangeRate = !!exchangeRate; if (internalPrimaryCurrencyIsCrypto) { inputValueConversion = `${balanceToFiatNumber( processedInputValue, conversionRate, - exchangeRatePrice, + exchangeRate, )}`; - displayableInputValueConversion = `${balanceToFiat( + renderableInputValueConversion = `${balanceToFiat( processedInputValue, conversionRate, - exchangeRatePrice, + exchangeRate, currentCurrency, )}`; } else { @@ -1181,12 +1007,12 @@ class Amount extends PureComponent { fiatNumberToTokenMinimalUnit( processedInputValue, conversionRate, - exchangeRatePrice, + exchangeRate, selectedAsset.decimals, ), selectedAsset.decimals, )}`; - displayableInputValueConversion = `${inputValueConversion} ${selectedAsset.symbol}`; + renderableInputValueConversion = `${inputValueConversion} ${selectedAsset.symbol}`; } } if (comma) inputValue = inputValue && inputValue.replace('.', ','); @@ -1195,7 +1021,7 @@ class Amount extends PureComponent { this.setState({ inputValue, inputValueConversion, - displayableInputValueConversion, + renderableInputValueConversion, amountError: undefined, hasExchangeRate, maxFiatInput: !useMax && undefined, @@ -1207,52 +1033,20 @@ class Amount extends PureComponent { this.setState({ assetsModalVisible: !assetsModalVisible }); }; - handleSelectedAssetBalance = (selectedAsset, displayableBalance) => { - const { - accounts, - accountsByContextualChainId, - selectedAddress, - contractBalances, - contextualChainId, - allTokenBalances, - } = this.props; - const contextuallySelectedAccount = - accountsByContextualChainId?.[selectedAddress]; - + handleSelectedAssetBalance = (selectedAsset, renderableBalance) => { + const { accounts, selectedAddress, contractBalances } = this.props; let currentBalance; - if (displayableBalance) { - currentBalance = `${displayableBalance} ${selectedAsset.symbol || ''}`; + if (renderableBalance) { + currentBalance = `${renderableBalance} ${selectedAsset.symbol}`; } else if (isNativeToken(selectedAsset)) { - const globallySelectedBalance = - accounts?.[selectedAddress]?.balance || '0x0'; - const contextuallySelectedBalance = - contextuallySelectedAccount?.balance || '0x0'; - const balanceToRender = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedBalance - : globallySelectedBalance; - const balanceValue = renderFromWei(balanceToRender) || '0'; - const symbol = selectedAsset.symbol || 'ETH'; - currentBalance = `${balanceValue} ${symbol}`; + currentBalance = `${renderFromWei(accounts[selectedAddress].balance)} ${ + selectedAsset.symbol + }`; } else { - const globallySelectedBalance = - contractBalances?.[selectedAsset.address] || '0x0'; - const contextuallySelectedBalance = - allTokenBalances?.[selectedAddress?.toLowerCase()]?.[ - contextualChainId - ]?.[selectedAsset.address] || '0x0'; - const balanceMinUnit = - renderFromTokenMinimalUnit( - contextuallySelectedBalance, - selectedAsset.decimals, - ) || '0'; - const balanceToRender = isRemoveGlobalNetworkSelectorEnabled() - ? balanceMinUnit - : renderFromTokenMinimalUnit( - globallySelectedBalance, - selectedAsset.decimals, - ) || '0'; - const symbol = selectedAsset.symbol || ''; - currentBalance = `${balanceToRender} ${symbol}`; + currentBalance = `${renderFromTokenMinimalUnit( + contractBalances[selectedAsset.address], + selectedAsset.decimals, + )} ${selectedAsset.symbol}`; } this.setState({ currentBalance }); }; @@ -1260,7 +1054,6 @@ class Amount extends PureComponent { pickSelectedAsset = (selectedAsset) => { this.toggleAssetsModal(); this.props.setSelectedAsset(selectedAsset); - if (!selectedAsset.tokenId) { this.onInputChange(undefined, selectedAsset); this.handleSelectedAssetBalance(selectedAsset); @@ -1290,53 +1083,24 @@ class Amount extends PureComponent { currentCurrency, contractBalances, contractExchangeRates, - accountsByContextualChainId, - contextualChainId, - ticker, - tickerByContextualChainId, - contractExchangeRatesByContextualChainId, } = this.props; - const contextuallySelectedAccount = - accountsByContextualChainId?.[selectedAddress]; - let balance, balanceFiat; const { address, decimals, symbol } = token; const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); if (isNativeToken(token)) { - const globallySelectedBalance = - accounts?.[selectedAddress]?.balance || '0x0'; - const contextuallySelectedBalance = - contextuallySelectedAccount?.balance || '0x0'; - const balanceToRender = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedBalance - : globallySelectedBalance; - - balance = renderFromWei(balanceToRender) || '0'; + balance = renderFromWei(accounts[selectedAddress].balance); balanceFiat = weiToFiat( - hexToBN(balanceToRender), + hexToBN(accounts[selectedAddress].balance), conversionRate, currentCurrency, ); } else { - const globallySelectedBalance = contractBalances?.[address] || '0x0'; - const contextuallySelectedBalance = - this.props.allTokenBalances?.[selectedAddress?.toLowerCase()]?.[ - contextualChainId - ]?.[address] || '0x0'; - const balanceToRender = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedBalance - : globallySelectedBalance; - balance = renderFromTokenMinimalUnit(balanceToRender, decimals) || '0'; - const globallySelectedExchangeRate = - contractExchangeRates?.[address]?.price ?? null; - const contextuallySelectedExchangeRate = - contractExchangeRatesByContextualChainId?.[address]?.price ?? null; - const exchangeRate = isRemoveGlobalNetworkSelectorEnabled() - ? contextuallySelectedExchangeRate - : globallySelectedExchangeRate; - + balance = renderFromTokenMinimalUnit(contractBalances[address], decimals); + const exchangeRate = contractExchangeRates + ? contractExchangeRates[address]?.price + : undefined; balanceFiat = balanceToFiat( balance, conversionRate, @@ -1354,15 +1118,7 @@ class Amount extends PureComponent { > {isNativeToken(token) ? ( - + ) : ( { const { inputValue, - displayableInputValueConversion, + renderableInputValueConversion, amountError, hasExchangeRate, internalPrimaryCurrencyIsCrypto, @@ -1576,7 +1332,7 @@ class Amount extends PureComponent { placeholder={'0'} placeholderTextColor={colors.text.muted} keyboardAppearance={themeAppearance} - testID={AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT} + testID={AmountViewSelectorsIDs.AMOUNT_INPUT} /> @@ -1595,7 +1351,7 @@ class Amount extends PureComponent { AmountViewSelectorsIDs.TRANSACTION_AMOUNT_CONVERSION_VALUE } > - {displayableInputValueConversion} + {renderableInputValueConversion} - {isRemoveGlobalNetworkSelectorEnabled() ? ( - - ) : null} {!hasExchangeRate && !selectedAsset.tokenId ? ( - + {strings('transaction.use_max')} @@ -1824,16 +1564,6 @@ const mapStateToProps = (state, ownProps) => { const transaction = ownProps.transaction || state.transaction; const globalChainId = selectEvmChainId(state); const globalNetworkClientId = selectNetworkClientId(state); - const contextualChainId = - selectSendFlowContextualChainId(state) || globalChainId; - const contextualNetworkConfiguration = selectNetworkConfigurationByChainId( - state, - toHexadecimal(contextualChainId), - ); - - const currentChainId = isRemoveGlobalNetworkSelectorEnabled() - ? contextualChainId - : globalChainId; return { accounts: selectAccounts(state), @@ -1844,46 +1574,27 @@ const mapStateToProps = (state, ownProps) => { contractBalances: selectContractBalances(state), collectibles: collectiblesSelector(state), collectibleContracts: collectibleContractsSelector(state), - conversionRate: selectConversionRateByChainId(state, currentChainId), + conversionRate: selectConversionRateByChainId(state, globalChainId), currentCurrency: selectCurrentCurrency(state), gasEstimateType: selectGasFeeControllerEstimateType(state), gasFeeEstimates: selectGasFeeEstimates(state), - providerType: selectProviderTypeByChainId(state, currentChainId), + providerType: selectProviderTypeByChainId(state, globalChainId), primaryCurrency: state.settings.primaryCurrency, selectedAddress: selectSelectedInternalAccountFormattedAddress(state), - ticker: selectNativeCurrencyByChainId(state, currentChainId), + ticker: selectNativeCurrencyByChainId(state, globalChainId), tokens: selectTokens(state), transactionState: transaction, selectedAsset: state.transaction.selectedAsset, isPaymentRequest: state.transaction.paymentRequest, isNetworkBuyNativeTokenSupported: isNetworkRampNativeTokenSupported( - currentChainId, + globalChainId, getRampNetworks(state), ), isRedesignedTransferConfirmationEnabledForTransfer: selectConfirmationRedesignFlags(state).transfer, - swapsIsLive: selectIsSwapsLive(state, currentChainId), + swapsIsLive: selectIsSwapsLive(state, globalChainId), globalChainId, globalNetworkClientId, - contextualChainId, - contextualNetworkConfiguration, - accountsByContextualChainId: selectAccountsByContextualChainId(state), - contractExchangeRatesByContextualChainId: - selectContractExchangeRatesByChainId(state, contextualChainId), - contractBalancesByContextualChainId: - selectContractBalancesByContextualChainId(state), - allTokenBalances: selectAllTokenBalances(state), - tickerByContextualChainId: selectNativeCurrencyByChainId( - state, - contextualChainId, - ), - allTokensByChainId: selectAllTokens(state), - networkName: - selectNetworkConfigurations(state)?.[currentChainId]?.name || '', - networkImageSource: selectNetworkImageSourceByChainId( - state, - currentChainId, - ), }; }; @@ -1892,10 +1603,7 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(prepareTransaction(transaction)), setSelectedAsset: (selectedAsset) => dispatch(setSelectedAsset(selectedAsset)), - resetTransaction: () => { - dispatch(setTransactionSendFlowContextualChainId(null)); - dispatch(resetTransaction()); - }, + resetTransaction: () => dispatch(resetTransaction()), setMaxValueMode: (maxValueMode) => dispatch(setMaxValueMode(maxValueMode)), }); diff --git a/app/components/Views/confirmations/legacy/SendFlow/Amount/index.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/Amount/index.test.tsx index 1e8cd8f66c4d..abb05a5e289b 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Amount/index.test.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/Amount/index.test.tsx @@ -117,19 +117,6 @@ jest.mock( }), ); -// Mock the networks utility function -jest.mock('../../../../../../util/networks', () => ({ - ...jest.requireActual('../../../../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn().mockReturnValue(false), -})); - -// Get reference to the mocked function -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; -const mockIsRemoveGlobalNetworkSelectorEnabled = - isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction< - typeof isRemoveGlobalNetworkSelectorEnabled - >; - const mockNavigate = jest.fn(); const CURRENT_ACCOUNT = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; @@ -216,15 +203,6 @@ const initialState = { showFiatOnTestnets: true, primaryCurrency: 'ETH', }, - networkOnboarded: { - networkOnboardedState: {}, - sendFlowChainId: null, - }, - transaction: { - selectedAsset: {}, - transaction: {}, - transactionTo: '', - }, }; const Stack = createStackNavigator(); @@ -1300,897 +1278,4 @@ describe('Amount', () => { }, ); }); - - // Tests for isRemoveGlobalNetworkSelectorEnabled feature - describe('isRemoveGlobalNetworkSelectorEnabled', () => { - afterEach(() => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReset(); - }); - - describe('contextual network selector enabled', () => { - beforeEach(() => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - }); - - it('uses contextual chain data for tokens when flag is enabled', () => { - const contextualChainId = '0xa86a'; - const contextualTokens = [ - { - address: '0x123', - symbol: 'AVAX', - decimals: 18, - name: 'Avalanche', - }, - ]; - - const stateWithContextualData = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - TokensController: { - allTokens: { - [contextualChainId]: { - [CURRENT_ACCOUNT.toLowerCase()]: contextualTokens, - }, - }, - }, - NetworkController: { - ...initialState.engine.backgroundState.NetworkController, - networkConfigurationsByChainId: { - [contextualChainId]: { - blockExplorerUrls: ['https://snowtrace.io'], - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: 'AVAX', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - }; - - renderComponent(stateWithContextualData); - - // Should show contextual network picker - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('uses contextual account balances when flag is enabled', () => { - const contextualChainId = '0xa86a'; - const contextualBalance = '0x1BC16D674EC80000'; // 2 ETH - - const stateWithContextualBalance = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - ...initialState.engine.backgroundState.AccountTrackerController, - accountsByChainId: { - '0xaa36a7': { - [CURRENT_ACCOUNT]: { - balance: '0x0', - }, - }, - [contextualChainId]: { - [CURRENT_ACCOUNT]: { - balance: contextualBalance, - }, - }, - }, - }, - NetworkController: { - ...initialState.engine.backgroundState.NetworkController, - networkConfigurationsByChainId: { - '0xaa36a7': { - blockExplorerUrls: ['https://etherscan.com'], - chainId: '0xaa36a7', - defaultRpcEndpointIndex: 0, - name: 'Sepolia', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'sepolia', - type: 'Custom', - url: 'http://localhost/v3/', - }, - ], - }, - [contextualChainId]: { - blockExplorerUrls: ['https://snowtrace.io'], - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: 'AVAX', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - ...initialState.transaction, - selectedAsset: { - address: '', - isETH: false, - isNative: true, - symbol: 'AVAX', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByText } = renderComponent(stateWithContextualBalance); - - const balanceText = getByText(/Balance:/); - expect(balanceText.props.children).toBe('Balance: 2 AVAX'); - }); - - it('uses contextual token balances when flag is enabled', () => { - const contextualChainId = '0xaa36a7'; // Use same format as global state - const tokenAddress = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; - const contextualTokenBalance = '0x1BC16D674EC80000'; // 2 LINK tokens - - const stateWithContextualTokenBalance = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountsController: { - ...initialState.engine.backgroundState.AccountsController, - internalAccounts: { - selectedAccount: CURRENT_ACCOUNT, - accounts: { - [CURRENT_ACCOUNT]: { - address: CURRENT_ACCOUNT, - }, - }, - }, - }, - TokenBalancesController: { - tokenBalances: { - [CURRENT_ACCOUNT.toLowerCase()]: { - [contextualChainId]: { - [tokenAddress]: contextualTokenBalance, - }, - }, - }, - }, - TokensController: { - allTokens: { - [contextualChainId]: { - [CURRENT_ACCOUNT.toLowerCase()]: [ - { - address: tokenAddress, - symbol: 'LINK', - decimals: 18, - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - ...initialState.transaction, - selectedAsset: { - address: tokenAddress, - symbol: 'LINK', - decimals: 18, - isETH: false, - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const { getByText } = renderComponent(stateWithContextualTokenBalance); - - const balanceText = getByText(/Balance:/); - expect(balanceText.props.children).toBe(`Balance: 2 LINK`); - }); - - it('uses contextual exchange rates when flag is enabled', () => { - const contextualChainId = '0xaa36a7'; - const tokenAddress = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; - - const stateWithContextualExchangeRate = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - TokenRatesController: { - marketData: { - [contextualChainId]: { - [tokenAddress]: { price: 10.5 }, - }, - }, - }, - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - ETH: { - conversionRate: 1, - }, - }, - }, - AccountsController: { - ...initialState.engine.backgroundState.AccountsController, - internalAccounts: { - selectedAccount: CURRENT_ACCOUNT, - accounts: { - [CURRENT_ACCOUNT]: { - address: CURRENT_ACCOUNT, - }, - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - ...initialState.transaction, - selectedAsset: { - address: tokenAddress, - symbol: 'LINK', - decimals: 18, - isETH: false, - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const { getByTestId } = renderComponent( - stateWithContextualExchangeRate, - ); - - // Set input value to trigger conversion calculation - const amountInput = getByTestId(AmountViewSelectorsIDs.AMOUNT_INPUT); - fireEvent.changeText(amountInput, '1'); - - const amountConversionValue = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_CONVERSION_VALUE, - ); - expect(amountConversionValue.props.children).toBe('$10.50'); // 1 LINK * 10.5 - }); - - it('shows ContextualNetworkPicker when flag is enabled', () => { - const contextualChainId = '0xa86a'; - - const stateWithContextualNetwork = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - NetworkController: { - ...initialState.engine.backgroundState.NetworkController, - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: 'AVAX', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - ...initialState.transaction, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { toJSON } = renderComponent(stateWithContextualNetwork); - - // Should render ContextualNetworkPicker component - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - expect(toJSON()).toMatchSnapshot(); - }); - - it('uses contextual ticker for native asset display when flag is enabled', () => { - const contextualChainId = '0xa86a'; - const contextualTicker = 'AVAX'; - - const stateWithContextualTicker = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - NetworkController: { - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: contextualTicker, - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - selectedAsset: { - address: '', - isETH: false, - isNative: true, - symbol: contextualTicker, - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByText } = renderComponent(stateWithContextualTicker); - - const balanceText = getByText(/Balance:/); - expect(balanceText.props.children).toBe( - `Balance: 0 ${contextualTicker}`, - ); - }); - - it('validates amount against contextual balance when flag is enabled', async () => { - const contextualChainId = '0xa86a'; - const lowBalance = '0x0'; // 0 ETH - - const stateWithLowContextualBalance = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - [contextualChainId]: { - [CURRENT_ACCOUNT]: { - balance: lowBalance, - }, - }, - }, - }, - NetworkController: { - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: 'AVAX', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - selectedAsset: { - address: '', - isETH: true, - symbol: 'ETH', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByTestId, queryByText } = renderComponent( - stateWithLowContextualBalance, - ); - - const nextButton = getByTestId(AmountViewSelectorsIDs.NEXT_BUTTON); - await waitFor(() => expect(nextButton.props.disabled).toBe(false)); - - const textInput = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT, - ); - fireEvent.changeText(textInput, '1'); - - await act(() => fireEvent.press(nextButton)); - - expect(queryByText('Insufficient funds')).not.toBeNull(); - }); - - it('uses contextual network for transaction submission when flag is enabled', async () => { - mockSelectConfirmationRedesignFlags.mockReturnValue({ - transfer: true, - } as ReturnType); - - const contextualChainId = '0xa86a'; - const contextualNetworkClientId = 'avalanche-mainnet'; - - const stateWithContextualNetwork = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - [contextualChainId]: { - [CURRENT_ACCOUNT]: { - balance: '0x1BC16D674EC80000', // 2 ETH - }, - }, - }, - }, - NetworkController: { - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - name: 'Avalanche', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: contextualNetworkClientId, - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - transaction: { - selectedAsset: { - address: '', - isETH: true, - symbol: 'AVAX', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0xde0b6b3a7640000', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByTestId } = renderComponent(stateWithContextualNetwork); - - const nextButton = getByTestId(AmountViewSelectorsIDs.NEXT_BUTTON); - await waitFor(() => expect(nextButton.props.disabled).toBe(false)); - - const textInput = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT, - ); - fireEvent.changeText(textInput, '1'); - - await act(() => fireEvent.press(nextButton)); - - expect(addTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - }), - { - origin: 'metamask', - networkClientId: contextualNetworkClientId, - }, - ); - }); - }); - - describe('contextual network selector disabled (legacy behavior)', () => { - beforeEach(() => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - }); - - it('uses global chain data for tokens when flag is disabled', () => { - const globalTokens = [ - { - address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', - symbol: 'LINK', - decimals: 18, - }, - ]; - - const stateWithGlobalData = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - TokensController: { - allTokens: { - '0xaa36a7': { - [CURRENT_ACCOUNT.toLowerCase()]: globalTokens, - }, - }, - }, - }, - }, - transaction: { - ...initialState.transaction, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { queryByTestId } = renderComponent(stateWithGlobalData); - - // Should not show contextual network picker - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - // ContextualNetworkPicker should not be rendered - expect(queryByTestId('contextual-network-picker')).toBeNull(); - }); - - it('uses global account balances when flag is disabled', () => { - const globalBalance = '0x1BC16D674EC80000'; // 2 ETH - - const stateWithGlobalBalance = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0xaa36a7': { - [CURRENT_ACCOUNT]: { - balance: globalBalance, - }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: { - address: '', - isETH: true, - symbol: 'ETH', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByText } = renderComponent(stateWithGlobalBalance); - - const balanceText = getByText(/Balance:/); - expect(balanceText.props.children).toBe('Balance: 2 ETH'); - }); - - it('uses global exchange rates when flag is disabled', () => { - const tokenAddress = '0x514910771AF9Ca656af840dff83E8264EcF986CA'; - - const stateWithGlobalRates = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - TokenRatesController: { - marketData: { - '0xaa36a7': { - [tokenAddress]: { price: 15.0 }, // Global rate - }, - }, - }, - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - ETH: { - conversionRate: 2000, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: { - address: tokenAddress, - symbol: 'LINK', - decimals: 18, - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByTestId } = renderComponent(stateWithGlobalRates); - - const textInput = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT, - ); - fireEvent.changeText(textInput, '1'); - - const amountConversionValue = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_CONVERSION_VALUE, - ); - expect(amountConversionValue.props.children).toBe('$30000.00'); // 1 LINK * 15.0 * 2000 - }); - - it('uses global network client ID for transaction submission when flag is disabled', async () => { - mockSelectConfirmationRedesignFlags.mockReturnValue({ - transfer: true, - } as ReturnType); - - const globalNetworkClientId = 'sepolia'; - - const stateWithGlobalNetwork = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0xaa36a7': { - [CURRENT_ACCOUNT]: { - balance: '0x1BC16D674EC80000', // 2 ETH - }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: { - address: '', - isETH: true, - symbol: 'ETH', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0xde0b6b3a7640000', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByTestId } = renderComponent(stateWithGlobalNetwork); - - const nextButton = getByTestId(AmountViewSelectorsIDs.NEXT_BUTTON); - await waitFor(() => expect(nextButton.props.disabled).toBe(false)); - - const textInput = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT, - ); - fireEvent.changeText(textInput, '1'); - - await act(() => fireEvent.press(nextButton)); - - expect(addTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - }), - { - origin: 'metamask', - networkClientId: globalNetworkClientId, - }, - ); - }); - }); - - describe('max value calculation with contextual data', () => { - beforeEach(() => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - }); - - it('calculates max value using contextual native balance when flag is enabled', async () => { - const contextualChainId = '0xa86a'; - const contextualBalance = '0x4563918244F40000'; // 5 ETH - - const stateWithContextualBalance = { - ...initialState, - engine: { - ...initialState.engine, - backgroundState: { - ...initialState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - [contextualChainId]: { - [CURRENT_ACCOUNT]: { - balance: contextualBalance, - }, - }, - }, - }, - CurrencyRateController: { - currentCurrency: 'usd', - currencyRates: { - AVAX: { - conversionRate: 50, - }, - }, - }, - NetworkController: { - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - name: 'Avalanche', - nativeCurrency: 'AVAX', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'avalanche-mainnet', - type: 'Custom', - url: 'https://api.avax.network/ext/bc/C/rpc', - }, - ], - }, - }, - }, - }, - }, - networkOnboarded: { - ...initialState.networkOnboarded, - sendFlowChainId: contextualChainId, - }, - settings: { - primaryCurrency: 'Fiat', - }, - transaction: { - selectedAsset: { - address: '', - isETH: false, - isNative: true, - symbol: 'AVAX', - }, - transaction: { - from: CURRENT_ACCOUNT, - to: RECEIVER_ACCOUNT, - value: '0x0', - data: '0x', - }, - transactionTo: RECEIVER_ACCOUNT, - }, - }; - - const { getByText, getByTestId } = renderComponent( - stateWithContextualBalance, - ); - - const nextButton = getByTestId(AmountViewSelectorsIDs.NEXT_BUTTON); - await waitFor(() => expect(nextButton.props.disabled).toBe(false)); - - const useMaxButton = getByText(/Use max/); - await act(async () => { - fireEvent.press(useMaxButton); - }); - - const amountInput = getByTestId( - AmountViewSelectorsIDs.TRANSACTION_AMOUNT_INPUT, - ); - - // Should use contextual balance for max calculation - expect(amountInput.props.value).toBeDefined(); - expect(typeof amountInput.props.value).toBe('string'); - // Value should be less than 250 (5 * 50) due to gas estimation - expect(parseFloat(amountInput.props.value || '0')).toBeLessThanOrEqual( - 250, - ); - expect(parseFloat(amountInput.props.value || '0')).toBeGreaterThan(0); - }); - }); - }); }); diff --git a/app/components/Views/confirmations/legacy/SendFlow/Confirm/__snapshots__/index.test.tsx.snap b/app/components/Views/confirmations/legacy/SendFlow/Confirm/__snapshots__/index.test.tsx.snap index 22a848b67165..a247bc3c044d 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Confirm/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/confirmations/legacy/SendFlow/Confirm/__snapshots__/index.test.tsx.snap @@ -757,7 +757,7 @@ exports[`Confirm should render correctly 1`] = ` } } > - Ethereum Main Network + Ethereum Network default RPC balance by chain id - */ - contractBalancesByChainId: PropTypes.object, - /** - * Object containing accounts in the format address => account by chain id - */ - accountsByChainId: PropTypes.object, - /** - * Object containing network configuration by chain id - */ - sendFlowContextualNetworkConfiguration: PropTypes.object, }; state = { @@ -439,7 +411,6 @@ class Confirm extends PureComponent { const { contractBalances, transactionState: { selectedAsset }, - contractBalancesByChainId, } = this.props; const { transactionMeta } = this.state; @@ -464,11 +435,7 @@ class Confirm extends PureComponent { return; } - const currentContractBalances = isRemoveGlobalNetworkSelectorEnabled() - ? contractBalancesByChainId || {} - : contractBalances; - - const weiBalance = hexToBN(currentContractBalances[selectedAsset.address]); + const weiBalance = hexToBN(contractBalances[selectedAsset.address]); if (weiBalance?.isZero()) { await TokensController.ignoreTokens( [selectedAsset.address], @@ -545,12 +512,8 @@ class Confirm extends PureComponent { ); } // add transaction - const { TransactionController, NetworkController } = Engine.context; + const { TransactionController } = Engine.context; const transactionParams = this.prepareTransactionToSend(); - const currentNetworkClientId = isRemoveGlobalNetworkSelectorEnabled() - ? NetworkController.findNetworkClientIdByChainId(chainId) || - globalNetworkClientId - : globalNetworkClientId; let result, transactionMeta; try { @@ -558,7 +521,7 @@ class Confirm extends PureComponent { transactionParams, { deviceConfirmedOn: WalletDevice.MM_MOBILE, - networkClientId: currentNetworkClientId, + networkClientId: globalNetworkClientId, origin: TransactionTypes.MMM, }, )); @@ -770,43 +733,12 @@ class Confirm extends PureComponent { const { prepareTransaction, transactionState: { transaction }, - networkClientId, - sendFlowContextualNetworkConfiguration, } = this.props; - - const effectiveNetworkClientId = this.getEffectiveNetworkClientId( - networkClientId, - sendFlowContextualNetworkConfiguration, - ); - - const estimation = await getGasLimit( - transaction, - true, - effectiveNetworkClientId, - ); - + const { networkClientId } = this.props; + const estimation = await getGasLimit(transaction, true, networkClientId); prepareTransaction({ ...transaction, ...estimation }); }; - getEffectiveNetworkClientId = ( - networkClientId, - sendFlowContextualNetworkConfiguration, - ) => { - if (!isRemoveGlobalNetworkSelectorEnabled()) { - return networkClientId; - } - - const { rpcEndpoints, defaultRpcEndpointIndex } = - sendFlowContextualNetworkConfiguration || {}; - - if (!rpcEndpoints || defaultRpcEndpointIndex === undefined) { - return networkClientId; - } - - const defaultEndpoint = rpcEndpoints[defaultRpcEndpointIndex]; - return defaultEndpoint?.networkClientId || networkClientId; - }; - parseTransactionDataHeader = async () => { const { contractBalances, @@ -943,9 +875,6 @@ class Confirm extends PureComponent { transaction: { value }, }, updateConfirmationMetric, - sendFlowContextualChainId, - contractBalancesByChainId, - accountsByChainId, } = this.props; const { EIP1559GasTransaction, legacyGasTransaction, transactionMeta } = this.state; @@ -962,12 +891,7 @@ class Confirm extends PureComponent { const totalTransactionValue = transactionValueHex.add(transactionFeeMax); const selectedAddress = transaction?.from; - const currentAccountBalance = isRemoveGlobalNetworkSelectorEnabled() - ? accountsByChainId?.[sendFlowContextualChainId]?.[transaction?.from] - ?.balance - : accounts[selectedAddress].balance; - - const weiBalance = hexToBN(currentAccountBalance || '0x0'); + const weiBalance = hexToBN(accounts[selectedAddress].balance); if (!isDecimal(value)) { return strings('transaction.invalid_amount'); @@ -994,13 +918,9 @@ class Confirm extends PureComponent { return insufficientBalanceMessage; } - const currentContractBalances = isRemoveGlobalNetworkSelectorEnabled() - ? contractBalancesByChainId || {} - : contractBalances; - const insufficientTokenBalanceMessage = validateSufficientTokenBalance( transaction, - currentContractBalances, + contractBalances, selectedAsset, ); @@ -1421,12 +1341,6 @@ class Confirm extends PureComponent { async persistTransactionParameters(transactionParams) { const { TransactionController } = Engine.context; const { transactionMeta } = this.state; - - if (!transactionMeta?.id) { - Logger.error('Transaction meta or ID not available', transactionMeta); - return; - } - const { id: transactionId } = transactionMeta; const controllerTransactionMeta = @@ -1434,11 +1348,6 @@ class Confirm extends PureComponent { (tx) => tx.id === transactionId, ); - if (!controllerTransactionMeta) { - Logger.log('Transaction not found in controller state'); - return; - } - const updatedTx = { ...controllerTransactionMeta, txParams: { @@ -1687,25 +1596,14 @@ Confirm.contextType = ThemeContext; const mapStateToProps = (state) => { const transaction = getNormalizedTxState(state); const chainId = transaction?.chainId || selectEvmChainId(state); - const sendFlowContextualChainId = - selectSendFlowContextualChainId(state) || chainId; const networkClientId = transaction?.networkClientId || selectNetworkClientId(state); - const currentChainId = isRemoveGlobalNetworkSelectorEnabled() - ? sendFlowContextualChainId - : chainId; - - const transactionState = { - ...state.transaction, - chainId: currentChainId, - }; return { accounts: selectAccounts(state), contractExchangeRates: selectContractExchangeRatesByChainId(state, chainId), contractBalances: selectContractBalances(state), - contractBalancesByChainId: selectContractBalancesByContextualChainId(state), conversionRate: selectConversionRateByChainId(state, chainId), currentCurrency: selectCurrentCurrency(state), providerType: selectProviderTypeByChainId(state, chainId), @@ -1717,7 +1615,7 @@ const mapStateToProps = (state) => { ticker: selectNativeCurrencyByChainId(state, chainId), transaction, selectedAsset: state.transaction.selectedAsset, - transactionState, + transactionState: state.transaction, primaryCurrency: state.settings.primaryCurrency, gasFeeEstimates: selectGasFeeEstimates(state), gasEstimateType: selectGasFeeControllerEstimateType(state), @@ -1730,27 +1628,14 @@ const mapStateToProps = (state) => { confirmationMetricsById: selectConfirmationMetrics(state), transactionMetadata: selectCurrentTransactionMetadata(state), securityAlertResponse: selectCurrentTransactionSecurityAlertResponse(state), - sendFlowContextualChainId, - sendFlowContextualNetworkConfiguration: selectNetworkConfigurationByChainId( - state, - toHexadecimal(sendFlowContextualChainId), - ), - allTokensByChainId: selectAllTokens(state), - accountsByChainId: selectAccountsByChainId(state), - contractExchangeRatesByChainId: selectContractExchangeRatesByChainId( - state, - sendFlowContextualChainId, - ), + maxValueMode: state.transaction.maxValueMode, }; }; const mapDispatchToProps = (dispatch) => ({ prepareTransaction: (transaction) => dispatch(prepareTransaction(transaction)), - resetTransaction: () => { - dispatch(setTransactionSendFlowContextualChainId(null)); - dispatch(resetTransaction()); - }, + resetTransaction: () => dispatch(resetTransaction()), setTransactionId: (transactionId) => dispatch(setTransactionId(transactionId)), setNonce: (nonce) => dispatch(setNonce(nonce)), diff --git a/app/components/Views/confirmations/legacy/SendFlow/Confirm/index.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/Confirm/index.test.tsx index 1b21d783c3ce..3d36c5fe54d0 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/Confirm/index.test.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/Confirm/index.test.tsx @@ -19,18 +19,6 @@ import { ConfirmViewSelectorsIDs } from '../../../../../../../e2e/selectors/Send import { updateConfirmationMetric } from '../../../../../../core/redux/slices/confirmationMetrics'; import Engine from '../../../../../../core/Engine'; import { flushPromises } from '../../../../../../util/test/utils'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; - -// Mock the feature flag function -jest.mock('../../../../../../util/networks', () => ({ - ...jest.requireActual('../../../../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn(), -})); - -const mockIsRemoveGlobalNetworkSelectorEnabled = - isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction< - typeof isRemoveGlobalNetworkSelectorEnabled - >; const MOCK_ADDRESS = '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58'; @@ -67,15 +55,6 @@ const mockInitialState: DeepPartial = { AccountTrackerController: { accountsByChainId: { '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1000000000000000000', - }, - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - '0x89': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x2000000000000000000', - }, '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, }, }, @@ -133,9 +112,6 @@ const mockInitialState: DeepPartial = { }, ], }, - networkOnboarded: { - sendFlowChainId: null, - }, }; jest.mock('../../../../../Views/confirmations/hooks/useNetworkInfo', () => ({ @@ -191,10 +167,6 @@ jest.mock('../../../../../../core/Engine', () => { }, ], }, - resetQRKeyringState: jest.fn(), - }, - ApprovalController: { - accept: jest.fn().mockResolvedValue({}), }, TransactionController: { addTransaction: jest.fn().mockResolvedValue({ @@ -214,24 +186,13 @@ jest.mock('../../../../../../core/Engine', () => { ...mockAccountsControllerState, state: mockAccountsControllerState, }, - NetworkController: { - findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'), - }, }, }; }); jest.mock('../../../../../../util/custom-gas', () => ({ ...jest.requireActual('../../../../../../util/custom-gas'), - getGasLimit: jest.fn().mockResolvedValue({ - gas: '0x5208', - gasLimit: '0x5208', - }), -})); - -jest.mock('../../../../../../util/transaction-controller', () => ({ - ...jest.requireActual('../../../../../../util/transaction-controller'), - getNetworkNonce: jest.fn().mockResolvedValue('0x5'), + getGasLimit: jest.fn(), })); jest.mock('../../../../../../util/transactions', () => ({ ...jest.requireActual('../../../../../../util/transactions'), @@ -242,20 +203,10 @@ jest.mock('../../../../../../core/redux/slices/confirmationMetrics', () => ({ ...jest.requireActual( '../../../../../../core/redux/slices/confirmationMetrics', ), - updateConfirmationMetric: jest - .fn() - .mockReturnValue({ type: 'UPDATE_CONFIRMATION_METRIC', payload: {} }), + updateConfirmationMetric: jest.fn(), selectConfirmationMetrics: jest.fn().mockReturnValue({}), })); -jest.mock('../../../../../../core/ClipboardManager', () => ({ - setString: jest.fn(), -})); - -jest.mock('../../../../../../core/NotificationManager', () => ({ - watchSubmittedTransaction: jest.fn(), -})); - jest.mock('../../../../../../reducers/swaps', () => ({ swapsStateSelector: () => ({ featureFlags: { @@ -295,12 +246,6 @@ function render( describe('Confirm', () => { const mockUpdateConfirmationMetric = jest.mocked(updateConfirmationMetric); - beforeEach(() => { - jest.clearAllMocks(); - // Default to feature flag disabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - }); - it('should render correctly', async () => { const wrapper = render(Confirm); await waitFor(() => { @@ -409,522 +354,4 @@ describe('Confirm', () => { expect(Engine.context.TokensController.addToken).toHaveBeenCalled(); }); - - it('should display hex data button when showHexData is enabled', async () => { - const stateWithHexData = merge({}, mockInitialState, { - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1000000000000000000', - }, - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - chainId: '0x1', - transaction: { - from: '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58', - to: '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A', - value: '0x2', - data: '0xa9059cbb000000000000000000000000e64dd0ab5ad7e8c5f2bf6ce75c34e187af8b920a0000000000000000000000000000000000000000000000000000000000000002', - }, - }, - settings: { - showHexData: true, - }, - }); - - const { queryByText } = render(Confirm, stateWithHexData); - - // Wait for component to fully render and the transaction to be added - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Wait for gas estimation to complete which is needed to show the hex data button - await waitFor( - () => { - const hexDataButton = queryByText('Hex Data'); - expect(hexDataButton).toBeDefined(); - }, - { timeout: 10000 }, - ); - }); - - it('should render send button with correct disabled state', async () => { - const stateWithSufficientBalance = merge({}, mockInitialState, { - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1000000000000000000', - }, - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - chainId: '0x1', - transaction: { - from: '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58', - to: '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A', - value: '0x2', - }, - }, - }); - - const { getByTestId } = render(Confirm, stateWithSufficientBalance); - - // Wait for component to fully render and transaction to be added - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the send button exists and has the expected attributes - const sendButton = getByTestId(ConfirmViewSelectorsIDs.SEND_BUTTON); - expect(sendButton).toBeDefined(); - - // The button should initially be disabled while gas estimation is happening - expect(sendButton.props.disabled).toBe(true); - - // Verify the button contains the expected text - expect(sendButton.props.children.props.children[0]).toBe('Send'); - }); - - it('should display error message when balance is insufficient', async () => { - const stateWithInsufficientBalance = merge({}, mockInitialState, { - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1', - }, // Very low balance - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - }, - }, - GasFeeController: { - gasFeeEstimates: { - low: { - suggestedMaxPriorityFeePerGas: '1', - suggestedMaxFeePerGas: '20', - }, - medium: { - suggestedMaxPriorityFeePerGas: '2', - suggestedMaxFeePerGas: '30', - }, - high: { - suggestedMaxPriorityFeePerGas: '3', - suggestedMaxFeePerGas: '40', - }, - }, - gasEstimateType: 'fee-market', - }, - }, - }, - transaction: { - selectedAsset: {}, - chainId: '0x1', - transaction: { - from: '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58', - to: '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A', - value: '0x1000000000000000000', // 1 ETH - more than the balance - gas: '0x5208', - }, - }, - }); - - const { queryByText } = render(Confirm, stateWithInsufficientBalance); - - // Wait for component to fully render and transaction to be added - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Wait for gas estimation and validation to complete - await waitFor( - () => { - const errorMessage = queryByText('Insufficient funds'); - expect(errorMessage).toBeDefined(); - }, - { timeout: 5000 }, - ); - }); - - it('should not render custom nonce section when showCustomNonce is disabled', async () => { - const stateWithoutCustomNonce = merge({}, mockInitialState, { - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1000000000000000000', - }, - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - chainId: '0x1', - transaction: { - from: '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58', - to: '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A', - value: '0x2', - }, - }, - settings: { - showCustomNonce: false, // Explicitly disabled - }, - }); - - const { queryByText } = render(Confirm, stateWithoutCustomNonce); - - // Wait for component to fully render and transaction to be added - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify that nonce section is not rendered when disabled - const nonceText = queryByText('Nonce'); - expect(nonceText).toBeNull(); - }); - - it('should display amount label for regular ETH transactions', async () => { - const stateWithETH = merge({}, mockInitialState, { - engine: { - backgroundState: { - ...backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58': { - balance: '0x1000000000000000000', - }, - '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A': { balance: '0' }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: { isETH: true, symbol: 'ETH' }, // Regular ETH transaction - chainId: '0x1', - transaction: { - from: '0x15249D1a506AFC731Ee941d0D40Cf33FacD34E58', - to: '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A', - value: '0x1000000000000000000', // 1 ETH - }, - }, - }); - - const { queryByText } = render(Confirm, stateWithETH); - - // Wait for component to fully render and transaction to be added - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Check that regular ETH transaction shows "Amount" label instead of "Asset" - await waitFor(() => { - const amountLabel = queryByText('Amount'); - expect(amountLabel).toBeDefined(); - }); - - // Should not show "Asset" label for regular ETH transactions - const assetLabel = queryByText('Asset'); - expect(assetLabel).toBeNull(); - }); - - describe('Contextual Send Flow Feature Flag', () => { - describe('feature flag behavior', () => { - it('should call isRemoveGlobalNetworkSelectorEnabled feature flag function', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - render(Confirm); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify that the feature flag function was called - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('should respect feature flag when disabled', async () => { - // Mock feature flag as disabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - const stateWithContextualChainId = merge({}, mockInitialState, { - networkOnboarded: { - sendFlowChainId: '0x89', // This should be ignored when flag is disabled - }, - }); - - render(Confirm, stateWithContextualChainId); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // When flag is disabled, the feature flag function should be called - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('should respect feature flag when enabled', async () => { - // Mock feature flag as enabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const stateWithContextualChainId = merge({}, mockInitialState, { - networkOnboarded: { - sendFlowChainId: '0x89', // This should be used when flag is enabled - }, - }); - - render(Confirm, stateWithContextualChainId); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // When flag is enabled, the feature flag function should be called - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - }); - - describe('contextual chain ID usage patterns', () => { - it('should handle contextual chain ID when present', async () => { - const stateWithContextualChainId = merge({}, mockInitialState, { - networkOnboarded: { - sendFlowChainId: '0x89', // Polygon contextual chain ID - }, - }); - - render(Confirm, stateWithContextualChainId); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the component handled the contextual chain ID - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('should handle when contextual chain ID is null', async () => { - const stateWithoutContextualChainId = merge({}, mockInitialState, { - networkOnboarded: { - sendFlowChainId: null, // No contextual chain ID - }, - }); - - render(Confirm, stateWithoutContextualChainId); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the component handled the null contextual chain ID - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('should handle when contextual chain ID is undefined', async () => { - const stateWithUndefinedContextualChainId = merge( - {}, - mockInitialState, - { - networkOnboarded: { - sendFlowChainId: undefined, // Undefined contextual chain ID - }, - }, - ); - - render(Confirm, stateWithUndefinedContextualChainId); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the component handled the undefined contextual chain ID - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - - it('should handle when networkOnboarded state is missing', async () => { - const stateWithoutNetworkOnboarded = merge({}, mockInitialState); - delete stateWithoutNetworkOnboarded.networkOnboarded; - - render(Confirm, stateWithoutNetworkOnboarded); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the component handled the missing networkOnboarded state - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - }); - }); - - describe('feature flag state combinations', () => { - it('should handle various combinations of feature flag and contextual chain ID', async () => { - const testCases = [ - { - flagEnabled: false, - contextualChainId: '0x1', - description: 'flag disabled with contextual chain ID', - }, - { - flagEnabled: false, - contextualChainId: null, - description: 'flag disabled with null contextual chain ID', - }, - { - flagEnabled: true, - contextualChainId: '0x89', - description: 'flag enabled with contextual chain ID', - }, - { - flagEnabled: true, - contextualChainId: null, - description: 'flag enabled with null contextual chain ID', - }, - ]; - - for (const testCase of testCases) { - // Clear mocks for each test case - jest.clearAllMocks(); - Engine.context.TransactionController.addTransaction = jest - .fn() - .mockResolvedValue({ - result: {}, - transactionMeta: { id: Math.random() }, - }); - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue( - testCase.flagEnabled, - ); - - const testState = merge({}, mockInitialState, { - networkOnboarded: { - sendFlowChainId: testCase.contextualChainId, - }, - }); - - render(Confirm, testState); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify the feature flag was called for each test case - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalled(); - } - }); - }); - - describe('integration with existing functionality', () => { - it('should not break existing transaction processing when feature flag is disabled', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - render(Confirm); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify basic transaction processing still works - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalledWith( - expect.objectContaining({ - chainId: expect.any(String), - from: expect.any(String), - to: expect.any(String), - value: expect.any(String), - }), - expect.objectContaining({ - deviceConfirmedOn: expect.any(String), - networkClientId: expect.any(String), - origin: expect.any(String), - }), - ); - }); - - it('should not break existing transaction processing when feature flag is enabled', async () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - render(Confirm); - - await waitFor(() => { - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalled(); - }); - - // Verify basic transaction processing still works - expect( - Engine.context.TransactionController.addTransaction, - ).toHaveBeenCalledWith( - expect.objectContaining({ - chainId: expect.any(String), - from: expect.any(String), - to: expect.any(String), - value: expect.any(String), - }), - expect.objectContaining({ - deviceConfirmedOn: expect.any(String), - networkClientId: expect.any(String), - origin: expect.any(String), - }), - ); - }); - }); - }); }); diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js index 0237a8c14b91..ed529e0aba4a 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js +++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.js @@ -51,7 +51,6 @@ import { selectEvmChainId, selectNativeCurrencyByChainId, selectProviderTypeByChainId, - selectNetworkConfigurationByChainId, selectNetworkConfigurations, } from '../../../../../../selectors/networkController'; import { @@ -68,12 +67,9 @@ import { includes } from 'lodash'; import { SendViewSelectorsIDs } from '../../../../../../../e2e/selectors/SendFlow/SendView.selectors'; import { withMetricsAwareness } from '../../../../../../components/hooks/useMetrics'; import { selectAddressBook } from '../../../../../../selectors/addressBookController'; -import { selectSendFlowContextualChainId } from '../../../../../../selectors/sendFlow'; -import { setTransactionSendFlowContextualChainId } from '../../../../../../actions/sendFlow'; -import { toHexadecimal } from '../../../../../../util/number'; -import { selectNetworkImageSourceByChainId } from '../../../../../../selectors/networkInfos'; +import ContextualNetworkPicker from '../../../../../UI/ContextualNetworkPicker'; +import { selectNetworkImageSource } from '../../../../../../selectors/networkInfos'; import { NETWORK_SELECTOR_SOURCES } from '../../../../../../constants/networkSelector'; -import ContextualNetworkPicker from '../../../../../UI/ContextualNetworkPicker/ContextualNetworkPicker'; const dummy = () => true; @@ -155,18 +151,6 @@ class SendFlow extends PureComponent { * Metrics injected by withMetricsAwareness HOC */ metrics: PropTypes.object, - /** - * Send flow contextual chain id - */ - contextualChainId: PropTypes.string, - /** - * Send flow contextual network configuration - */ - contextualNetworkConfiguration: PropTypes.object, - /** - * Set transaction send flow contextual chain id - */ - setTransactionContextualChainId: PropTypes.func, /** * Network name */ @@ -196,6 +180,7 @@ class SendFlow extends PureComponent { updateNavBar = () => { const { navigation, route, resetTransaction } = this.props; const colors = this.context.colors || mockTheme.colors; + navigation.setOptions( getSendFlowTitle( 'send.send_to', @@ -217,8 +202,6 @@ class SendFlow extends PureComponent { providerType, route, isPaymentRequest, - setTransactionContextualChainId, - newAssetTransaction, } = this.props; this.updateNavBar(); // For analytics @@ -234,23 +217,18 @@ class SendFlow extends PureComponent { //Fills in to address and sets the transaction if coming from QR code scan const targetAddress = route.params?.txMeta?.target_address; if (targetAddress) { - newAssetTransaction(getEther(ticker)); + this.props.newAssetTransaction(getEther(ticker)); this.onToSelectedAddressChange(targetAddress); } // Disabling back press for not be able to exit the send flow without reseting the transaction object this.hardwareBackPress = () => true; BackHandler.addEventListener('hardwareBackPress', this.hardwareBackPress); - - // Initialize contextual chain ID with global chain ID - if (isRemoveGlobalNetworkSelectorEnabled()) { - setTransactionContextualChainId(globalChainId); - } }; - componentDidUpdate() { + componentDidUpdate = () => { this.updateNavBar(); - } + }; componentWillUnmount() { BackHandler.removeEventListener( @@ -350,25 +328,25 @@ class SendFlow extends PureComponent { }; goToBuy = () => { - const { navigation, metrics, globalChainId } = this.props; - navigation.navigate(...createBuyNavigationDetails()); + this.props.navigation.navigate(...createBuyNavigationDetails()); - metrics.trackEvent( - metrics + this.props.metrics.trackEvent( + this.props.metrics .createEventBuilder(MetaMetricsEvents.BUY_BUTTON_CLICKED) .addProperties({ button_location: 'Send Flow warning', button_copy: 'Buy Native Token', - chain_id_destination: globalChainId, + chain_id_destination: this.props.globalChainId, }) .build(), ); }; renderBuyEth = () => { - const { isNativeTokenBuySupported } = this.props; + const colors = this.context.colors || mockTheme.colors; + const styles = createStyles(colors); - if (!isNativeTokenBuySupported) { + if (!this.props.isNativeTokenBuySupported) { return null; } @@ -469,9 +447,9 @@ class SendFlow extends PureComponent { }; onToSelectedAddressChange = (toAccount) => { - const { ambiguousAddressEntries, globalChainId } = this.props; const currentChain = - ambiguousAddressEntries && ambiguousAddressEntries[globalChainId]; + this.props.ambiguousAddressEntries && + this.props.ambiguousAddressEntries[this.props.globalChainId]; const isAmbiguousAddress = includes(currentChain, toAccount); if (isAmbiguousAddress) { this.setState({ showAmbiguousAcountWarning: isAmbiguousAddress }); @@ -481,7 +459,7 @@ class SendFlow extends PureComponent { MetaMetricsEvents.SEND_FLOW_SELECT_DUPLICATE_ADDRESS, ) .addProperties({ - chain_id: getDecimalChainId(globalChainId), + chain_id: getDecimalChainId(this.props.globalChainId), }) .build(), ); @@ -547,9 +525,8 @@ class SendFlow extends PureComponent { ticker, addressBook, globalChainId, - contextualChainId, - networkName, networkImageSource, + networkName, } = this.props; const { toAccount, @@ -567,11 +544,6 @@ class SendFlow extends PureComponent { const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); - const currentChainId = - isRemoveGlobalNetworkSelectorEnabled() && contextualChainId - ? contextualChainId - : globalChainId; - const checksummedAddress = this.safeChecksumAddress(toAccount); const existingAddressName = this.getAddressNameFromBookOrInternalAccounts( toEnsAddressResolved || toAccount, @@ -589,6 +561,7 @@ class SendFlow extends PureComponent { const explanations = displayConfusableWarning && getConfusablesExplanations(confusableCollection); + return ( ) : null} - + @@ -768,11 +741,6 @@ SendFlow.contextType = ThemeContext; const mapStateToProps = (state) => { const globalChainId = selectEvmChainId(state); - const contextualChainId = - selectSendFlowContextualChainId(state) || globalChainId; - const currentChainId = isRemoveGlobalNetworkSelectorEnabled() - ? contextualChainId - : globalChainId; return { addressBook: selectAddressBook(state), @@ -780,25 +748,17 @@ const mapStateToProps = (state) => { selectedAddress: selectSelectedInternalAccountFormattedAddress(state), selectedAsset: state.transaction.selectedAsset, internalAccounts: selectInternalAccounts(state), - ticker: selectNativeCurrencyByChainId(state, currentChainId), - providerType: selectProviderTypeByChainId(state, currentChainId), + ticker: selectNativeCurrencyByChainId(state, globalChainId), + providerType: selectProviderTypeByChainId(state, globalChainId), isPaymentRequest: state.transaction.paymentRequest, isNativeTokenBuySupported: isNetworkRampNativeTokenSupported( - currentChainId, + globalChainId, getRampNetworks(state), ), ambiguousAddressEntries: state.user.ambiguousAddressEntries, - contextualChainId, - contextualNetworkConfiguration: selectNetworkConfigurationByChainId( - state, - toHexadecimal(contextualChainId), - ), + networkImageSource: selectNetworkImageSource(state, globalChainId), networkName: - selectNetworkConfigurations(state)?.[currentChainId]?.name || '', - networkImageSource: selectNetworkImageSourceByChainId( - state, - currentChainId, - ), + selectNetworkConfigurations(state)?.[globalChainId]?.name || '', }; }; @@ -824,12 +784,7 @@ const mapDispatchToProps = (dispatch) => ({ setSelectedAsset: (selectedAsset) => dispatch(setSelectedAsset(selectedAsset)), showAlert: (config) => dispatch(showAlert(config)), - resetTransaction: () => { - dispatch(setTransactionSendFlowContextualChainId(null)); - dispatch(resetTransaction()); - }, - setTransactionContextualChainId: (chainId) => - dispatch(setTransactionSendFlowContextualChainId(chainId)), + resetTransaction: () => dispatch(resetTransaction()), }); export default connect( diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.test.tsx b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.test.tsx index ed34b37acfd0..88c7c39b6985 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.test.tsx +++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/index.test.tsx @@ -9,7 +9,6 @@ import { ThemeContext, mockTheme } from '../../../../../../util/theme'; import initialRootState from '../../../../../../util/test/initial-root-state'; import { validateAddressOrENS } from '../../../../../../util/address'; import { SendViewSelectorsIDs } from '../../../../../../../e2e/selectors/SendFlow/SendView.selectors'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../../../../util/networks'; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -26,39 +25,11 @@ jest.mock('../../../../../../util/address', () => ({ validateAddressOrENS: jest.fn(), })); -jest.mock('../../../../../../util/networks/handleNetworkSwitch', () => ({ - handleNetworkSwitch: jest.fn(), -})); - -jest.mock('../../../../../../util/networks', () => ({ - ...jest.requireActual('../../../../../../util/networks'), - isRemoveGlobalNetworkSelectorEnabled: jest.fn(() => false), -})); - -jest.mock('react-native', () => ({ - ...jest.requireActual('react-native'), - Alert: { - alert: jest.fn(), - }, -})); - const mockStore = configureStore(); const navigationPropMock = { setOptions: jest.fn(), setParams: jest.fn(), navigate: jest.fn(), - pop: jest.fn(), - dangerouslyGetParent: jest.fn(() => ({ - pop: jest.fn(), - })), - route: { - params: { - selectedAddress: '0xAddress1', - txMeta: { - target_address: '0xAddress2', - }, - }, - }, }; const routeMock = { params: {}, @@ -135,1431 +106,4 @@ describe('SendTo Component', () => { expect(expectedWarningMessage).toBeOnTheScreen(); }); - - it('validates address when to address changes', () => { - mockValidateAddressOrENS.mockResolvedValue( - {} as unknown as ReturnType, - ); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, '0x1234567890123456789012345678901234567890'); - - expect(mockValidateAddressOrENS).toHaveBeenCalledWith( - '0x1234567890123456789012345678901234567890', - expect.any(Object), - expect.any(Array), - expect.any(String), - ); - }); - - it('navigates to Amount screen with valid address', () => { - const { navigate } = navigationPropMock; - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: undefined, - addressReady: true, - toEnsAddress: '0x1234567890123456789012345678901234567890', - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, '0x1234567890123456789012345678901234567890'); - - const nextButton = screen.getByTestId( - SendViewSelectorsIDs.ADDRESS_BOOK_NEXT_BUTTON, - ); - fireEvent.press(nextButton); - - expect(navigate).toHaveBeenCalledWith('Amount'); - }); - - it('prevents navigation with invalid address', () => { - const { navigate } = navigationPropMock; - mockValidateAddressOrENS.mockResolvedValue({ - addressError: 'Invalid address', - toEnsName: undefined, - addressReady: false, - toEnsAddress: undefined, - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, 'invalid-address'); - - const nextButton = screen.getByTestId( - SendViewSelectorsIDs.ADDRESS_BOOK_NEXT_BUTTON, - ); - fireEvent.press(nextButton); - - expect(navigate).not.toHaveBeenCalled(); - }); - - it('disables Next button when address is not ready', () => { - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: undefined, - addressReady: false, - toEnsAddress: undefined, - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, '0x1234567890123456789012345678901234567890'); - - const nextButton = screen.getByTestId( - SendViewSelectorsIDs.ADDRESS_BOOK_NEXT_BUTTON, - ); - expect(nextButton.props.disabled).toBe(true); - }); - - it('resolves ENS name to address', () => { - const ensName = 'vitalik.eth'; - const resolvedAddress = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; - - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: ensName, - addressReady: true, - toEnsAddress: resolvedAddress, - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, ensName); - - expect(mockValidateAddressOrENS).toHaveBeenCalledWith( - ensName, - expect.any(Object), - expect.any(Array), - expect.any(String), - ); - }); - - it('shows error for invalid ENS name', () => { - const invalidEnsName = 'nonexistent.eth'; - - mockValidateAddressOrENS.mockResolvedValue({ - addressError: 'No address has been set for this name.', - toEnsName: invalidEnsName, - addressReady: false, - toEnsAddress: undefined, - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, invalidEnsName); - - expect(mockValidateAddressOrENS).toHaveBeenCalledWith( - invalidEnsName, - expect.any(Object), - expect.any(Array), - expect.any(String), - ); - }); - - it('shows warning for contract address', () => { - const contractAddress = '0xA0b86a33E6441b8c4C8C8C8C8C8C8C8C8C8C8C8'; - - mockValidateAddressOrENS.mockResolvedValue({ - addressError: 'SYMBOL_ERROR', - toEnsName: undefined, - addressReady: true, - toEnsAddress: contractAddress, - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: true, - isOnlyWarning: true, - confusableCollection: [], - } as unknown as ReturnType); - - render( - - - - - , - ); - - const toInput = screen.getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText(toInput, contractAddress); - - expect(mockValidateAddressOrENS).toHaveBeenCalledWith( - contractAddress, - expect.any(Object), - expect.any(Array), - expect.any(String), - ); - }); - - describe('Contextual Chain ID logic', () => { - const mockIsRemoveGlobalNetworkSelectorEnabled = jest.mocked( - isRemoveGlobalNetworkSelectorEnabled, - ); - - const createMockStore = ( - contextualChainId?: string, - globalChainId = '0x1', - ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const state: any = { - ...initialRootState, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - NetworkController: { - ...initialRootState.engine.backgroundState.NetworkController, - selectedNetworkClientId: 'mainnet', - providerConfig: { - chainId: globalChainId, - ticker: 'ETH', - type: 'mainnet', - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }; - - // Set up networkOnboarded state for contextual chain ID - state.networkOnboarded = { - sendFlowChainId: contextualChainId || null, - networkOnboardedState: {}, - networkState: { - showNetworkOnboarding: false, - nativeToken: '', - networkType: '', - networkUrl: '', - }, - switchedNetwork: { - networkUrl: '', - networkStatus: false, - }, - }; - - const mockStoreWithActions = configureStore([]); - const storeWithActions = mockStoreWithActions(state); - storeWithActions.dispatch = jest - .fn() - .mockImplementation((action) => action); - - return storeWithActions; - }; - - beforeEach(() => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - }); - - it('uses global chain ID when contextual chain ID is not set', () => { - const globalChainId = '0x1'; - const testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - // Verify the component rendered with global chain ID - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('uses contextual chain ID when set and feature flag is enabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const contextualChainId = '0xa'; - const globalChainId = '0x1'; - const testStore = createMockStore(contextualChainId, globalChainId); - - render( - - - - - , - ); - - // Component should use contextual chain ID - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('falls back to global chain ID when contextual chain ID is null and feature flag is enabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const globalChainId = '0x1'; - const testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('always uses global chain ID when feature flag is disabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const contextualChainId = '0xa'; - const globalChainId = '0x1'; - const testStore = createMockStore(contextualChainId, globalChainId); - - render( - - - - - , - ); - - // Should use global chain ID even though contextual is set - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('initializes contextual chain ID on mount when feature flag is enabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const globalChainId = '0x1'; - const testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - // Should dispatch action to set contextual chain ID - expect(testStore.dispatch).toHaveBeenCalledWith({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - chainId: globalChainId, - }); - }); - - it('does not initialize contextual chain ID when feature flag is disabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const globalChainId = '0x1'; - const testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - // Should not dispatch action when feature is disabled - expect(testStore.dispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - }), - ); - }); - - it('resets contextual chain ID when transaction is reset', () => { - const testStore = createMockStore('0xa', '0x1'); - testStore.dispatch = jest.fn(); - - render( - - - - - , - ); - - // Get resetTransaction from props passed to component - const resetTransactionProp = - navigationPropMock.setOptions.mock.calls[0][0]; - - // Reset transaction should dispatch both actions - const resetButton = resetTransactionProp.headerRight(); - resetButton.props.onPress(); - - expect(testStore.dispatch).toHaveBeenCalledWith({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - chainId: null, - }); - }); - - it('uses contextual chain ID for ticker selection when feature is enabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const contextualChainId = '0xa'; // Optimism - const globalChainId = '0x1'; // Mainnet - - const optimismState = { - ...initialRootState, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - NetworkController: { - ...initialRootState.engine.backgroundState.NetworkController, - selectedNetworkClientId: 'mainnet', - providerConfig: { - chainId: globalChainId, - ticker: 'ETH', - type: 'mainnet', - }, - networksMetadata: { - [contextualChainId]: { - EIPS: {}, - status: 'available', - }, - }, - networkConfigurationsByChainId: { - [contextualChainId]: { - chainId: contextualChainId, - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'optimism', - url: 'https://mainnet.optimism.io', - type: 'custom', - }, - ], - }, - }, - }, - CurrencyRateController: { - ...initialRootState.engine.backgroundState.CurrencyRateController, - currencyRates: { - ETH: { - conversionRate: 1, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - sendFlow: { - contextualChainId, - }, - }; - - const optimismStore = configureStore([])(optimismState); - - render( - - - - - , - ); - - // Component should render with contextual chain ticker - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - describe('Feature Flag Behavior Tests', () => { - it('should show contextual network picker only when feature flag is enabled', () => { - // Test with feature flag enabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const testStore = createMockStore('0xa', '0x1'); - - render( - - - - - , - ); - - // Should show contextual network picker when feature flag is enabled - // The contextual network picker is rendered when the feature flag is true - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Test with feature flag disabled - create a new render since rerender doesn't work with different mocks - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const testStore2 = createMockStore('0xa', '0x1'); - - const { unmount } = render( - - - - - , - ); - - // Should still render the main container when feature flag is disabled - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - unmount(); - }); - - it('should handle feature flag toggle during component lifecycle', () => { - // Start with feature flag disabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const testStore = createMockStore(undefined, '0x1'); - - const { rerender } = render( - - - - - , - ); - - // Should not initialize contextual chain ID - expect(testStore.dispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - }), - ); - - // Enable feature flag and re-render - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - rerender( - - - - - , - ); - - // Component should adapt to feature flag change - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('should use correct chain ID based on feature flag state', () => { - const contextualChainId = '0xa'; - const globalChainId = '0x1'; - - // Test with feature flag disabled - should use global chain ID - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - let testStore = createMockStore(contextualChainId, globalChainId); - - render( - - - - - , - ); - - // Component should render successfully with global chain ID - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Test with feature flag enabled - should use contextual chain ID - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - testStore = createMockStore(contextualChainId, globalChainId); - - render( - - - - - , - ); - - // Component should render successfully with contextual chain ID - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('should handle address book filtering based on feature flag', () => { - // Feature flag disabled - should filter address book by current chain - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - let testStore = createMockStore(undefined, '0x1'); - - render( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Feature flag enabled - should show all address book entries - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - testStore = createMockStore('0xa', '0x1'); - - render( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('should maintain component functionality when feature flag is disabled', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const testStore = createMockStore('0xa', '0x1'); - - render( - - - - - , - ); - - // Basic functionality should still work - const addressInput = screen.getByTestId( - SendViewSelectorsIDs.ADDRESS_INPUT, - ); - expect(addressInput).toBeTruthy(); - - // Should be able to input addresses - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - expect(addressInput.props.value).toBe( - '0x1234567890123456789012345678901234567890', - ); - }); - - it('should handle contextual chain ID reset based on feature flag', () => { - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const testStore = createMockStore('0xa', '0x1'); - testStore.dispatch = jest.fn(); - - render( - - - - - , - ); - - // Get reset function from navigation options - const resetTransactionProp = - navigationPropMock.setOptions.mock.calls[0][0]; - - if (resetTransactionProp?.headerRight) { - const resetButton = resetTransactionProp.headerRight(); - if (resetButton?.props?.onPress) { - resetButton.props.onPress(); - - // Should dispatch reset action when feature flag is enabled - expect(testStore.dispatch).toHaveBeenCalledWith({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - chainId: null, - }); - } - } - }); - - it('should handle feature flag function errors gracefully', () => { - // First, test with feature flag working normally - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - const testStore = createMockStore('0xa', '0x1'); - - render( - - - - - , - ); - - // Should render the container successfully - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Note: We can't easily test the error case because the feature flag - // is called during mapStateToProps which happens before render - // In a real app, this would be handled by error boundaries - }); - - it('should handle multiple feature flag state changes', () => { - const contextualChainId = '0x38'; - const globalChainId = '0x1'; - - // Start with feature flag enabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - const testStore = createMockStore(contextualChainId, globalChainId); - - const { rerender } = render( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Disable feature flag - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - - rerender( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - - // Re-enable feature flag - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - rerender( - - - - - , - ); - - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('should properly initialize contextual chain ID only when feature flag is enabled', () => { - const globalChainId = '0x1'; // Using the same chain ID that's set in createMockStore - - // Test with feature flag enabled - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - let testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - // Should initialize contextual chain ID with global chain ID - expect(testStore.dispatch).toHaveBeenCalledWith({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - chainId: globalChainId, - }); - - // Reset mocks and test with feature flag disabled - jest.clearAllMocks(); - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - testStore = createMockStore(undefined, globalChainId); - - render( - - - - - , - ); - - // Should not initialize contextual chain ID when feature flag is disabled - expect(testStore.dispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID', - }), - ); - }); - }); - }); - - describe('Network Switch Functionality', () => { - it('handles network switch successfully', () => { - const { handleNetworkSwitch } = jest.mocked( - jest.requireMock('../../../../../../util/networks/handleNetworkSwitch'), - ); - - handleNetworkSwitch.mockReturnValue('Ethereum Mainnet'); - - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // The component should handle network switch internally - expect(handleNetworkSwitch).toBeDefined(); - }); - - it('handles network switch with missing network ID error', () => { - const { handleNetworkSwitch } = jest.mocked( - jest.requireMock('../../../../../../util/networks/handleNetworkSwitch'), - ); - const { NetworkSwitchErrorType } = jest.mocked( - jest.requireMock('../../../../../../constants/error'), - ); - - handleNetworkSwitch.mockImplementation(() => { - throw new Error(NetworkSwitchErrorType.missingNetworkId); - }); - - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should handle the error gracefully - expect(handleNetworkSwitch).toBeDefined(); - }); - - it('handles network switch with general error', () => { - const { handleNetworkSwitch } = jest.mocked( - jest.requireMock('../../../../../../util/networks/handleNetworkSwitch'), - ); - - handleNetworkSwitch.mockImplementation(() => { - throw new Error('Some network error'); - }); - - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should handle the error gracefully - expect(handleNetworkSwitch).toBeDefined(); - }); - }); - - describe('Balance and Buy ETH Functionality', () => { - it('shows buy ETH option when balance is zero and native token is supported', () => { - const testStore = mockStore({ - ...initialRootState, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - AccountTrackerController: { - accountsByChainId: { - '0x1': { - [navigationPropMock.route?.params?.selectedAddress as string]: - { - balance: '0x0', // Zero balance - }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - const { getByTestId } = render( - - - - - , - ); - - // Component should be rendered - expect(getByTestId(SendViewSelectorsIDs.CONTAINER_ID)).toBeTruthy(); - }); - - it('does not show buy ETH option when native token is not supported', () => { - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should render without buy ETH option - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('handles go to buy navigation', () => { - const mockCreateBuyNavigationDetails = jest.fn(() => [ - 'BuyRoute', - { params: {} }, - ]); - jest.doMock( - '../../../../../../components/UI/Ramp/Aggregator/routes/utils', - () => ({ - createBuyNavigationDetails: mockCreateBuyNavigationDetails, - }), - ); - - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should be rendered - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - }); - - describe('Address Validation and Error Handling', () => { - it('validates checksum address safely', () => { - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - - // Test with an invalid address that would throw in toChecksumAddress - fireEvent.changeText(addressInput, 'invalid-address-that-throws'); - - // Component should handle the error gracefully - expect(addressInput).toBeTruthy(); - }); - - it('shows confusable character warning', () => { - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: undefined, - addressReady: true, - toEnsAddress: '0x1234567890123456789012345678901234567890', - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [ - { type: 'mixed', sources: ['latin', 'cyrillic'] }, - ], - } as unknown as ReturnType); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should handle confusable characters - expect(addressInput).toBeTruthy(); - }); - - it('shows confusable character warning as warning only', () => { - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: undefined, - addressReady: true, - toEnsAddress: '0x1234567890123456789012345678901234567890', - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: true, - confusableCollection: [{ type: 'single', source: 'cyrillic' }], - } as unknown as ReturnType); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should handle confusable characters as warning - expect(addressInput).toBeTruthy(); - }); - - it('handles saved address from address book', () => { - const testStore = mockStore({ - ...initialRootState, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - AddressBookController: { - addressBook: { - '0x1': { - '0x1234567890123456789012345678901234567890': { - name: 'Saved Address', - address: '0x1234567890123456789012345678901234567890', - }, - }, - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should handle saved address - expect(addressInput).toBeTruthy(); - }); - }); - - describe('Ambiguous Address Handling', () => { - it('shows ambiguous address warning', () => { - const testStore = mockStore({ - ...initialRootState, - user: { - ...initialRootState.user, - ambiguousAddressEntries: { - '0x1': ['0x1234567890123456789012345678901234567890'], - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should handle ambiguous address - expect(addressInput).toBeTruthy(); - }); - - it('dismisses ambiguous address warning', () => { - const testStore = mockStore({ - ...initialRootState, - user: { - ...initialRootState.user, - ambiguousAddressEntries: { - '0x1': ['0x1234567890123456789012345678901234567890'], - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should handle dismissing warnings - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('navigates to ambiguous address modal', () => { - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should be able to navigate to ambiguous address modal - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - }); - - describe('Network Selector Integration', () => { - it('navigates to network selector when feature flag is enabled', () => { - const mockIsRemoveGlobalNetworkSelectorEnabled = jest.mocked( - isRemoveGlobalNetworkSelectorEnabled, - ); - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - - const testStore = mockStore({ - ...initialRootState, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should render network picker when feature flag is enabled - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - }); - - describe('Component Lifecycle and State Management', () => { - it('handles input focus events', () => { - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - - // Test focus events - fireEvent(addressInput, 'focus'); - fireEvent(addressInput, 'blur'); - - expect(addressInput).toBeTruthy(); - }); - - it('handles component unmount cleanup', () => { - const { unmount } = render( - - - - - , - ); - - // Test unmount cleanup - unmount(); - - // Component should clean up properly - expect(true).toBe(true); - }); - - it('handles QR code scan target address', () => { - const routeWithTarget = { - params: { - txMeta: { - target_address: '0x1234567890123456789012345678901234567890', - }, - }, - }; - - render( - - - - - , - ); - - // Component should handle QR code target address - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - - it('auto-focuses address input when no address book entries exist', async () => { - const testStore = mockStore({ - ...initialRootState, - engine: { - ...initialRootState.engine, - backgroundState: { - ...initialRootState.engine.backgroundState, - AddressBookController: { - addressBook: { - '0x1': {}, // Empty address book - }, - }, - }, - }, - transaction: { - selectedAsset: {}, - }, - settings: { useBlockieIcon: false }, - }); - - render( - - - - - , - ); - - // Component should auto-focus input when address book is empty - expect( - screen.getByTestId(SendViewSelectorsIDs.CONTAINER_ID), - ).toBeTruthy(); - }); - }); - - describe('Address Book Integration', () => { - it('shows add to address book option for valid addresses', () => { - mockValidateAddressOrENS.mockResolvedValue({ - addressError: undefined, - toEnsName: undefined, - addressReady: true, - toEnsAddress: '0x1234567890123456789012345678901234567890', - addToAddressToAddressBook: true, - toAddressName: 'Test Address', - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should show add to address book option - expect(addressInput).toBeTruthy(); - }); - - it('handles contact already saved error', () => { - mockValidateAddressOrENS.mockResolvedValue({ - addressError: 'CONTACT_ALREADY_SAVED', - toEnsName: undefined, - addressReady: true, - toEnsAddress: '0x1234567890123456789012345678901234567890', - addToAddressToAddressBook: false, - toAddressName: undefined, - errorContinue: false, - isOnlyWarning: false, - confusableCollection: [], - } as unknown as ReturnType); - - const { getByTestId } = render( - - - - - , - ); - - const addressInput = getByTestId(SendViewSelectorsIDs.ADDRESS_INPUT); - fireEvent.changeText( - addressInput, - '0x1234567890123456789012345678901234567890', - ); - - // Component should handle contact already saved error - expect(addressInput).toBeTruthy(); - }); - }); }); diff --git a/app/components/Views/confirmations/legacy/SendFlow/SendTo/styles.ts b/app/components/Views/confirmations/legacy/SendFlow/SendTo/styles.ts index 8b3dfbafd2f9..78316787907a 100644 --- a/app/components/Views/confirmations/legacy/SendFlow/SendTo/styles.ts +++ b/app/components/Views/confirmations/legacy/SendFlow/SendTo/styles.ts @@ -1,14 +1,15 @@ import { StyleSheet } from 'react-native'; -import type { ThemeColors } from '@metamask/design-tokens'; import { fontStyles } from '../../../../../../styles/common'; -const createStyles = (colors: ThemeColors) => +// TODO: Replace "any" with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createStyles = (colors: any) => StyleSheet.create({ wrapper: { flex: 1, backgroundColor: colors.background.default, }, - inputWrapper: { + imputWrapper: { flex: 0, borderBottomWidth: 1, borderBottomColor: colors.border.muted, @@ -86,35 +87,6 @@ const createStyles = (colors: ThemeColors) => warningIcon: { marginRight: 8, }, - base: { - flexDirection: 'row', - paddingVertical: 5, - paddingHorizontal: 16, - borderRadius: 24, - borderWidth: 1, - width: '100%', - alignItems: 'center', - justifyContent: 'space-between', - borderColor: colors.border.muted, - }, - accountSelectorWrapper: { - height: 40, - backgroundColor: colors.background.default, - paddingHorizontal: 16, - flexDirection: 'row', - width: '100%', - marginBottom: 16, - }, - avatarWrapper: { - marginRight: 8, - alignItems: 'center', - justifyContent: 'center', - }, - row: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, }); export default createStyles; diff --git a/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.test.jsx b/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.test.jsx index 253f04415882..69513d403dd9 100644 --- a/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.test.jsx +++ b/app/components/Views/confirmations/legacy/components/ApproveTransactionReview/index.test.jsx @@ -239,64 +239,4 @@ describe('ApproveTransactionModal', () => { expect(isDisabled).toBe(true); }); }); - - it('fetchEstimatedL1Fee is called when isMultiLayerFeeNetwork is true', async () => { - const mockGetTokenDetails = getTokenDetails; - mockGetTokenDetails.mockReturnValue({}); - const state = cloneDeep(initialState); - state.engine.backgroundState.AccountTrackerController.accounts = []; - state.engine.backgroundState.TokenListController = { - tokensChainsCache: { - '0xa': { - data: [ - { - '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f': { - address: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', - symbol: 'SNX', - decimals: 18, - name: 'Synthetix Network Token', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f.png', - type: 'erc20', - aggregators: ['Aave'], - occurrences: 10, - fees: { - '0x5fd79d46eba7f351fe49bff9e87cdea6c821ef9f': 0, - '0xda4ef8520b1a57d7d63f1e249606d1a459698876': 0, - }, - }, - }, - ], - }, - }, - }; - - state.transaction = { - to: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', - origin: 'test-dapp', - chainId: '0xa', - txParams: { - to: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', - from: '0xc011a73ee8576fb46f5e1c5751ca3b9fe0af2a6f', - data, - origin: 'test-dapp', - }, - data, - }; - const mockOnConfirm = jest.fn(); - const { getByTestId } = renderScreen( - () => ( - // eslint-disable-next-line react/react-in-jsx-scope - - ), - { name: 'Approve' }, - { state }, - ); - - expect(mockGetTokenDetails).toHaveBeenCalled(); - await waitFor(() => { - const isDisabled = getByTestId('Confirm').props.disabled; - expect(isDisabled).toBe(true); - }); - }); }); diff --git a/app/components/hooks/useAddressBalance/useAddressBalance.test.tsx b/app/components/hooks/useAddressBalance/useAddressBalance.test.tsx index 6de7c5f79ce9..49a8d00fb892 100644 --- a/app/components/hooks/useAddressBalance/useAddressBalance.test.tsx +++ b/app/components/hooks/useAddressBalance/useAddressBalance.test.tsx @@ -11,14 +11,6 @@ import { createMockAccountsControllerState } from '../../../util/test/accountsCo import { SolScope } from '@metamask/keyring-api'; import type BN5 from 'bnjs5'; import { mockNetworkState } from '../../../util/test/network'; -import { isRemoveGlobalNetworkSelectorEnabled } from '../../../util/networks'; - -jest.mock('../../../util/networks', () => ({ - isPerDappSelectedNetworkEnabled: jest.fn(), - isRemoveGlobalNetworkSelectorEnabled: jest.fn(), - safeToChecksumAddress: jest.requireActual('../../../util/networks') - .safeToChecksumAddress, -})); const MOCK_ADDRESS_1 = '0x0'; const MOCK_ADDRESS_2 = '0x1'; @@ -93,7 +85,9 @@ jest.mock('react-redux', () => ({ .mockImplementation((callback) => callback(mockInitialState)), })); -const Wrapper = ({ children }: React.PropsWithChildren) => ( +// TODO: Replace "any" with type +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Wrapper = ({ children }: any) => ( {children} ); @@ -103,7 +97,6 @@ describe('useAddressBalance', () => { selectedAddress: string, networkClientId?: string | undefined, ) => Promise; - beforeEach(() => { mockGetERC20BalanceOf = jest .fn() @@ -112,15 +105,6 @@ describe('useAddressBalance', () => { Engine.context.AssetsContractController = { getERC20BalanceOf: mockGetERC20BalanceOf, }; - - // Set up NetworkController mock for all tests - ( - Engine.context as unknown as { - NetworkController: { findNetworkClientIdByChainId: jest.Mock }; - } - ).NetworkController = { - findNetworkClientIdByChainId: jest.fn(), - }; }); it('render balance from AccountTrackerController.accounts for ETH', () => { @@ -164,7 +148,7 @@ describe('useAddressBalance', () => { }); it('render balance if asset is undefined', () => { - let asset: Asset | undefined; + let asset: Asset; let res = renderHook(() => useAddressBalance(asset, MOCK_ADDRESS_1), { wrapper: Wrapper, }); @@ -196,363 +180,4 @@ describe('useAddressBalance', () => { expect(mockGetERC20BalanceOf).toBeCalledTimes(0); expect(res.result.current.addressBalance).toStrictEqual('0.0005 TST'); }); - - describe('networkClientId selection logic', () => { - const mockIsRemoveGlobalNetworkSelectorEnabled = - isRemoveGlobalNetworkSelectorEnabled as jest.MockedFunction< - typeof isRemoveGlobalNetworkSelectorEnabled - >; - const mockFindNetworkClientIdByChainId = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - - // Use the existing NetworkController mock and update its method - ( - Engine.context as unknown as { - NetworkController: { findNetworkClientIdByChainId: jest.Mock }; - } - ).NetworkController.findNetworkClientIdByChainId = - mockFindNetworkClientIdByChainId; - }); - - it('uses networkClientIdByChainId when global network selector is removed', async () => { - const networkClientIdByChainId = 'polygon-mainnet-client'; - const providedNetworkClientId = 'mainnet-client'; - const chainId = '0x89'; // Polygon - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockFindNetworkClientIdByChainId.mockReturnValue( - networkClientIdByChainId, - ); - - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136999', - symbol: 'MATIC', - decimals: 18, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith(chainId); - expect(mockGetERC20BalanceOf).toHaveBeenCalledWith( - '0x326836Cc6Cd09b5aA59B81A7f72f25fCc0136999', // Checksum address - MOCK_ADDRESS_1, - networkClientIdByChainId, // Should use networkClientIdByChainId - ); - }); - - it('uses provided networkClientId when global network selector is not removed', async () => { - const networkClientIdByChainId = 'polygon-mainnet-client'; - const providedNetworkClientId = 'mainnet-client'; - const chainId = '0x89'; // Polygon - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - mockFindNetworkClientIdByChainId.mockReturnValue( - networkClientIdByChainId, - ); - - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136888', - symbol: 'USDC', - decimals: 6, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // With the fix, findNetworkClientIdByChainId should NOT be called when feature flag is false - expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled(); - expect(mockGetERC20BalanceOf).toHaveBeenCalledWith( - '0x326836cc6Cd09B5Aa59B81a7F72f25fcc0136888', // Checksum address - MOCK_ADDRESS_1, - providedNetworkClientId, // Should use provided networkClientId - ); - }); - - it('calls findNetworkClientIdByChainId with correct chainId when feature flag is enabled', async () => { - const chainId = '0xa86a'; // Avalanche - const providedNetworkClientId = 'avalanche-client'; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockFindNetworkClientIdByChainId.mockReturnValue( - 'avalanche-mainnet-client', - ); - - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136777', - symbol: 'AVAX', - decimals: 18, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith(chainId); - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledTimes(1); - }); - - it('handles undefined chainId gracefully', async () => { - const providedNetworkClientId = 'mainnet-client'; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockFindNetworkClientIdByChainId.mockReturnValue( - 'mainnet-fallback-client', - ); - - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136666', - symbol: 'ETH', - decimals: 18, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - undefined, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // With the fix, findNetworkClientIdByChainId should NOT be called when chainId is undefined - expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled(); - expect(mockGetERC20BalanceOf).toHaveBeenCalledWith( - '0x326836cc6cd09B5AA59B81A7f72f25fCc0136666', // Checksum address - MOCK_ADDRESS_1, - providedNetworkClientId, // Should fallback to provided networkClientId - ); - }); - - it('falls back to provided networkClientId when findNetworkClientIdByChainId returns null', async () => { - const chainId = '0x89'; - const providedNetworkClientId = 'mainnet-client'; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockFindNetworkClientIdByChainId.mockReturnValue(null); - - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136555', - symbol: 'NULL', - decimals: 18, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // With the fix, when networkClientIdByChainId is null, it should fallback to providedNetworkClientId - expect(mockGetERC20BalanceOf).toHaveBeenCalledWith( - '0x326836cc6cD09B5aa59b81a7F72f25FCC0136555', // Checksum address - MOCK_ADDRESS_1, - providedNetworkClientId, // Should fallback to provided networkClientId instead of null - ); - }); - - it('verifies feature flag is called each time for different assets', async () => { - const chainId = '0x1'; - const providedNetworkClientId = 'mainnet-client'; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(false); - mockFindNetworkClientIdByChainId.mockReturnValue( - 'ethereum-mainnet-client', - ); - - const asset1 = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136444', - symbol: 'DAI', - decimals: 18, - }; - - const asset2 = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136333', - symbol: 'USDT', - decimals: 6, - }; - - (mockGetERC20BalanceOf as jest.Mock).mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve(0x0186a0), 50)), - ); - - // First asset - renderHook( - () => - useAddressBalance( - asset1, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Second asset - renderHook( - () => - useAddressBalance( - asset2, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockIsRemoveGlobalNetworkSelectorEnabled).toHaveBeenCalledTimes(2); - expect(mockGetERC20BalanceOf).toHaveBeenCalledTimes(2); - - // Both calls should use providedNetworkClientId since feature flag is false - expect(mockGetERC20BalanceOf).toHaveBeenNthCalledWith( - 1, - '0x326836cc6cd09B5aA59B81A7f72f25FCc0136444', // Checksum address - MOCK_ADDRESS_1, - providedNetworkClientId, - ); - expect(mockGetERC20BalanceOf).toHaveBeenNthCalledWith( - 2, - '0x326836cc6CD09B5AA59b81a7F72f25FCc0136333', // Checksum address - MOCK_ADDRESS_1, - providedNetworkClientId, - ); - }); - - it('does not call NetworkController methods when balance exists in contractBalances', () => { - const chainId = '0x1'; - const providedNetworkClientId = 'mainnet-client'; - - mockIsRemoveGlobalNetworkSelectorEnabled.mockReturnValue(true); - mockFindNetworkClientIdByChainId.mockReturnValue( - 'ethereum-mainnet-client', - ); - - // This asset already has balance in mockInitialState - const asset = { - address: '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95', - symbol: 'TST', - decimals: 4, - }; - - const res = renderHook( - () => - useAddressBalance( - asset, - MOCK_ADDRESS_1, - false, - chainId, - providedNetworkClientId, - ), - { - wrapper: Wrapper, - }, - ); - - // Should not call NetworkController methods since balance exists - expect(mockFindNetworkClientIdByChainId).not.toHaveBeenCalled(); - expect(mockGetERC20BalanceOf).not.toHaveBeenCalled(); - expect(res.result.current.addressBalance).toStrictEqual('0.0005 TST'); - }); - }); }); diff --git a/app/components/hooks/useAddressBalance/useAddressBalance.ts b/app/components/hooks/useAddressBalance/useAddressBalance.ts index 44702d872445..e22f3ee784db 100644 --- a/app/components/hooks/useAddressBalance/useAddressBalance.ts +++ b/app/components/hooks/useAddressBalance/useAddressBalance.ts @@ -21,10 +21,8 @@ import { } from '../../../selectors/accountTrackerController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import { Asset } from './useAddressBalance.types'; -import { - isPerDappSelectedNetworkEnabled, - isRemoveGlobalNetworkSelectorEnabled, -} from '../../../util/networks'; +import { RootState } from '../../../reducers'; +import { isPerDappSelectedNetworkEnabled } from '../../../util/networks'; import { safeToChecksumAddress, getTokenDetails } from '../../../util/address'; import { selectContractBalances, @@ -33,7 +31,6 @@ import { import { useAsyncResult } from '../useAsyncResult'; export const ERC20_DEFAULT_DECIMALS = 18; -import { RootState } from '../../../reducers'; const useAddressBalance = ( asset?: Asset, @@ -152,21 +149,11 @@ const useAddressBalance = ( } else { (async () => { try { - const { AssetsContractController, NetworkController } = - Engine.context; - let effectiveNetworkClientId = networkClientId; - - if (isRemoveGlobalNetworkSelectorEnabled() && chainId) { - const networkClientIdByChainId = - NetworkController.findNetworkClientIdByChainId(chainId as Hex); - if (networkClientIdByChainId) { - effectiveNetworkClientId = networkClientIdByChainId; - } - } + const { AssetsContractController } = Engine.context; fromAccBalance = await AssetsContractController.getERC20BalanceOf( contractAddress, address, - effectiveNetworkClientId, + networkClientId, ); fromAccBalance = `${renderFromTokenMinimalUnit( // This is to work around incompatibility between bn.js v4/v5 - should be removed when migration to v5 is complete diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index bbad455a47d1..a7558cf7513a 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -31,6 +31,9 @@ export enum ACTIONS { EMPTY = '', OAUTH_REDIRECT = 'oauth-redirect', CREATE_ACCOUNT = 'create-account', + PERPS = 'perps', + PERPS_MARKETS = 'perps-markets', + PERPS_ASSET = 'perps-asset', } export const PREFIXES = { @@ -51,5 +54,8 @@ export const PREFIXES = { [ACTIONS.HOME]: '', [ACTIONS.SWAP]: '', [ACTIONS.CREATE_ACCOUNT]: '', + [ACTIONS.PERPS]: '', + [ACTIONS.PERPS_MARKETS]: '', + [ACTIONS.PERPS_ASSET]: '', METAMASK: 'metamask://', }; diff --git a/app/constants/networkSelector.test.ts b/app/constants/networkSelector.test.ts deleted file mode 100644 index 7cd9730bbfff..000000000000 --- a/app/constants/networkSelector.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { - NETWORK_SELECTOR_SOURCES, - NETWORK_SELECTOR_SOURCE_VALUES, - NETWORK_SELECTOR_TEST_IDS, - NetworkSelectorSource, -} from './networkSelector'; - -describe('Network Selector Constants', () => { - describe('NETWORK_SELECTOR_SOURCES', () => { - it('should contain SEND_FLOW source', () => { - expect(NETWORK_SELECTOR_SOURCES.SEND_FLOW).toBe('SendFlow'); - }); - - it('should be an immutable constant through TypeScript', () => { - // The 'as const' assertion provides compile-time immutability - expect(typeof NETWORK_SELECTOR_SOURCES).toBe('object'); - }); - - it('should have expected structure', () => { - expect(NETWORK_SELECTOR_SOURCES).toEqual({ - SEND_FLOW: 'SendFlow', - }); - }); - - it('should not allow modifications in strict mode', () => { - // Note: Object.freeze only prevents modifications in strict mode - // Since TypeScript compiles to non-strict mode by default, we just verify the object structure - expect(() => { - const testObj = { ...NETWORK_SELECTOR_SOURCES }; - testObj.SEND_FLOW = 'Modified' as never; - }).not.toThrow(); - }); - - it('should maintain immutability through TypeScript typing', () => { - // The 'as const' assertion ensures compile-time immutability - // Runtime immutability depends on Object.freeze or strict mode - expect(NETWORK_SELECTOR_SOURCES.SEND_FLOW).toBe('SendFlow'); - }); - }); - - describe('NetworkSelectorSource type', () => { - it('should allow valid source values', () => { - const validSource: NetworkSelectorSource = 'SendFlow'; - expect(validSource).toBe('SendFlow'); - }); - - it('should match NETWORK_SELECTOR_SOURCES values', () => { - const sendFlowSource: NetworkSelectorSource = - NETWORK_SELECTOR_SOURCES.SEND_FLOW; - expect(sendFlowSource).toBe('SendFlow'); - }); - - it('should work with type checking', () => { - const checkType = (source: NetworkSelectorSource): string => source; - expect(checkType(NETWORK_SELECTOR_SOURCES.SEND_FLOW)).toBe('SendFlow'); - }); - }); - - describe('NETWORK_SELECTOR_SOURCE_VALUES', () => { - it('should contain all values from NETWORK_SELECTOR_SOURCES', () => { - expect(NETWORK_SELECTOR_SOURCE_VALUES).toEqual(['SendFlow']); - }); - - it('should be an array', () => { - expect(Array.isArray(NETWORK_SELECTOR_SOURCE_VALUES)).toBe(true); - }); - - it('should have the same number of values as NETWORK_SELECTOR_SOURCES', () => { - const sourceKeys = Object.keys(NETWORK_SELECTOR_SOURCES); - expect(NETWORK_SELECTOR_SOURCE_VALUES.length).toBe(sourceKeys.length); - }); - - it('should contain only string values', () => { - NETWORK_SELECTOR_SOURCE_VALUES.forEach((value) => { - expect(typeof value).toBe('string'); - }); - }); - - it('should be suitable for PropTypes validation', () => { - const isValidSource = (value: unknown): boolean => - NETWORK_SELECTOR_SOURCE_VALUES.includes(value as NetworkSelectorSource); - - expect(isValidSource('SendFlow')).toBe(true); - expect(isValidSource('InvalidSource')).toBe(false); - expect(isValidSource(undefined)).toBe(false); - expect(isValidSource(null)).toBe(false); - expect(isValidSource(123)).toBe(false); - }); - - it('should match all NETWORK_SELECTOR_SOURCES values', () => { - Object.values(NETWORK_SELECTOR_SOURCES).forEach((sourceValue) => { - expect(NETWORK_SELECTOR_SOURCE_VALUES).toContain(sourceValue); - }); - }); - - it('should not contain duplicate values', () => { - const uniqueValues = [...new Set(NETWORK_SELECTOR_SOURCE_VALUES)]; - expect(NETWORK_SELECTOR_SOURCE_VALUES.length).toBe(uniqueValues.length); - }); - }); - - describe('NETWORK_SELECTOR_TEST_IDS', () => { - it('should contain CONTEXTUAL_NETWORK_PICKER test ID', () => { - expect(NETWORK_SELECTOR_TEST_IDS.CONTEXTUAL_NETWORK_PICKER).toBe( - 'contextual-network-picker', - ); - }); - - it('should be an immutable constant through TypeScript', () => { - // The 'as const' assertion provides compile-time immutability - expect(typeof NETWORK_SELECTOR_TEST_IDS).toBe('object'); - }); - - it('should have expected structure', () => { - expect(NETWORK_SELECTOR_TEST_IDS).toEqual({ - CONTEXTUAL_NETWORK_PICKER: 'contextual-network-picker', - }); - }); - - it('should not allow modifications in strict mode', () => { - // Note: Object.freeze only prevents modifications in strict mode - // Since TypeScript compiles to non-strict mode by default, we just verify the object structure - expect(() => { - const testObj = { ...NETWORK_SELECTOR_TEST_IDS }; - testObj.CONTEXTUAL_NETWORK_PICKER = 'modified-id' as never; - }).not.toThrow(); - }); - - it('should maintain immutability through TypeScript typing', () => { - // The 'as const' assertion ensures compile-time immutability - // Runtime immutability depends on Object.freeze or strict mode - expect(NETWORK_SELECTOR_TEST_IDS.CONTEXTUAL_NETWORK_PICKER).toBe( - 'contextual-network-picker', - ); - }); - - it('should use kebab-case for test ID values', () => { - Object.values(NETWORK_SELECTOR_TEST_IDS).forEach((testId) => { - expect(testId).toMatch(/^[a-z]+(-[a-z]+)*$/); - }); - }); - - it('should have string values suitable for DOM test IDs', () => { - Object.values(NETWORK_SELECTOR_TEST_IDS).forEach((testId) => { - expect(typeof testId).toBe('string'); - expect(testId.length).toBeGreaterThan(0); - expect(testId).not.toContain(' '); - expect(testId).not.toContain('#'); - expect(testId).not.toContain('.'); - }); - }); - }); - - describe('Integration between constants', () => { - it('should maintain separate concerns between sources and test IDs', () => { - const sourceKeys = Object.keys(NETWORK_SELECTOR_SOURCES); - const testIdKeys = Object.keys(NETWORK_SELECTOR_TEST_IDS); - - // Ensure no accidental key overlap - sourceKeys.forEach((key) => { - expect(testIdKeys).not.toContain(key); - }); - }); - - it('should export all expected constants', () => { - // Verify all exports are defined - expect(NETWORK_SELECTOR_SOURCES).toBeDefined(); - expect(NETWORK_SELECTOR_SOURCE_VALUES).toBeDefined(); - expect(NETWORK_SELECTOR_TEST_IDS).toBeDefined(); - }); - - it('should maintain type safety for all exports', () => { - // Type checking at runtime - expect(typeof NETWORK_SELECTOR_SOURCES).toBe('object'); - expect(Array.isArray(NETWORK_SELECTOR_SOURCE_VALUES)).toBe(true); - expect(typeof NETWORK_SELECTOR_TEST_IDS).toBe('object'); - }); - }); - - describe('Usage patterns', () => { - it('should support switch statement usage with NETWORK_SELECTOR_SOURCES', () => { - const handleSource = (source: NetworkSelectorSource): string => { - switch (source) { - case NETWORK_SELECTOR_SOURCES.SEND_FLOW: - return 'Handling send flow'; - default: - return 'Unknown source'; - } - }; - - expect(handleSource(NETWORK_SELECTOR_SOURCES.SEND_FLOW)).toBe( - 'Handling send flow', - ); - }); - - it('should support array includes check with NETWORK_SELECTOR_SOURCE_VALUES', () => { - const isValidSource = (value: string): boolean => - NETWORK_SELECTOR_SOURCE_VALUES.includes(value as NetworkSelectorSource); - - expect(isValidSource('SendFlow')).toBe(true); - expect(isValidSource('InvalidSource')).toBe(false); - }); - - it('should support component test ID assignment', () => { - const getTestId = (component: string): string | undefined => { - if (component === 'ContextualNetworkPicker') { - return NETWORK_SELECTOR_TEST_IDS.CONTEXTUAL_NETWORK_PICKER; - } - return undefined; - }; - - expect(getTestId('ContextualNetworkPicker')).toBe( - 'contextual-network-picker', - ); - expect(getTestId('UnknownComponent')).toBeUndefined(); - }); - }); -}); diff --git a/app/core/DeeplinkManager/DeeplinkManager.test.ts b/app/core/DeeplinkManager/DeeplinkManager.test.ts index a0865fc09821..ea33da291e09 100644 --- a/app/core/DeeplinkManager/DeeplinkManager.test.ts +++ b/app/core/DeeplinkManager/DeeplinkManager.test.ts @@ -7,6 +7,10 @@ import switchNetwork from './Handlers/switchNetwork'; import parseDeeplink from './ParseManager/parseDeeplink'; import approveTransaction from './TransactionManager/approveTransaction'; import { RampType } from '../../reducers/fiatOrders/types'; +import { handleSwapUrl } from './Handlers/handleSwapUrl'; +import { handleCreateAccountUrl } from './Handlers/handleCreateAccountUrl'; +import { handlePerpsUrl, handlePerpsAssetUrl } from './Handlers/handlePerpsUrl'; +import Routes from '../../constants/navigation/Routes'; jest.mock('./TransactionManager/approveTransaction'); jest.mock('./Handlers/handleEthereumUrl'); @@ -14,6 +18,9 @@ jest.mock('./Handlers/handleBrowserUrl'); jest.mock('./Handlers/handleRampUrl'); jest.mock('./ParseManager/parseDeeplink'); jest.mock('./Handlers/switchNetwork'); +jest.mock('./Handlers/handleSwapUrl'); +jest.mock('./Handlers/handleCreateAccountUrl'); +jest.mock('./Handlers/handlePerpsUrl'); const mockNavigation = { navigate: jest.fn(), @@ -144,4 +151,42 @@ describe('DeeplinkManager', () => { onHandled, }); }); + + it('should handle open home correctly', () => { + deeplinkManager._handleOpenHome(); + expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + }); + + it('should handle swap correctly', () => { + const swapPath = '/swap/path'; + deeplinkManager._handleSwap(swapPath); + expect(handleSwapUrl).toHaveBeenCalledWith({ + swapPath, + }); + }); + + it('should handle create account correctly', () => { + const createAccountPath = '/create/account/path'; + deeplinkManager._handleCreateAccount(createAccountPath); + expect(handleCreateAccountUrl).toHaveBeenCalledWith({ + path: createAccountPath, + navigation: mockNavigation, + }); + }); + + it('should handle perps correctly', () => { + const perpsPath = '/perps/markets'; + deeplinkManager._handlePerps(perpsPath); + expect(handlePerpsUrl).toHaveBeenCalledWith({ + perpsPath, + }); + }); + + it('should handle perps asset correctly', () => { + const assetPath = '/BTC'; + deeplinkManager._handlePerpsAsset(assetPath); + expect(handlePerpsAssetUrl).toHaveBeenCalledWith({ + assetPath, + }); + }); }); diff --git a/app/core/DeeplinkManager/DeeplinkManager.ts b/app/core/DeeplinkManager/DeeplinkManager.ts index 99d2df0f36e0..84e887549017 100644 --- a/app/core/DeeplinkManager/DeeplinkManager.ts +++ b/app/core/DeeplinkManager/DeeplinkManager.ts @@ -13,6 +13,7 @@ import { RampType } from '../../reducers/fiatOrders/types'; import { handleSwapUrl } from './Handlers/handleSwapUrl'; import Routes from '../../constants/navigation/Routes'; import { handleCreateAccountUrl } from './Handlers/handleCreateAccountUrl'; +import { handlePerpsUrl, handlePerpsAssetUrl } from './Handlers/handlePerpsUrl'; class DeeplinkManager { public navigation: NavigationProp; @@ -109,6 +110,19 @@ class DeeplinkManager { navigation: this.navigation, }); } + + _handlePerps(perpsPath: string) { + handlePerpsUrl({ + perpsPath, + }); + } + + _handlePerpsAsset(assetPath: string) { + handlePerpsAssetUrl({ + assetPath, + }); + } + // NOTE: keeping this for backwards compatibility _handleOpenSwap() { this.navigation.navigate(Routes.SWAPS); diff --git a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts new file mode 100644 index 000000000000..823491885cf0 --- /dev/null +++ b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts @@ -0,0 +1,233 @@ +import { handlePerpsUrl, handlePerpsAssetUrl } from './handlePerpsUrl'; +import NavigationService from '../../NavigationService'; +import Routes from '../../../constants/navigation/Routes'; +import DevLogger from '../../SDKConnect/utils/DevLogger'; +import { PERFORMANCE_CONFIG } from '../../../components/UI/Perps/constants/perpsConfig'; +import { store } from '../../../store'; +import { selectIsFirstTimePerpsUser } from '../../../components/UI/Perps/selectors/perpsController'; + +// Mock dependencies +jest.mock('../../NavigationService'); +jest.mock('../../SDKConnect/utils/DevLogger'); +jest.mock('../../../store'); +jest.mock('../../../components/UI/Perps/selectors/perpsController'); + +describe('handlePerpsUrl', () => { + let mockNavigate: jest.Mock; + let mockSetParams: jest.Mock; + let mockGetState: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Setup navigation mocks + mockNavigate = jest.fn(); + mockSetParams = jest.fn(); + NavigationService.navigation = { + navigate: mockNavigate, + setParams: mockSetParams, + } as unknown as typeof NavigationService.navigation; + + // Mock DevLogger + (DevLogger.log as jest.Mock) = jest.fn(); + + // Mock store.getState + mockGetState = jest.fn(); + (store.getState as jest.Mock) = mockGetState; + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + describe('handlePerpsUrl', () => { + it('should navigate to tutorial for first-time users', async () => { + // Mock first-time user + jest.mocked(selectIsFirstTimePerpsUser).mockReturnValue(true); + + await handlePerpsUrl({ perpsPath: 'perps' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.TUTORIAL, + params: { + isFromDeeplink: true, + }, + }); + expect(mockSetParams).not.toHaveBeenCalled(); + }); + + it('should navigate to wallet home with Perps tab for returning users', async () => { + // Mock returning user + jest.mocked(selectIsFirstTimePerpsUser).mockReturnValue(false); + + await handlePerpsUrl({ perpsPath: 'perps' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME); + + // Fast-forward timer to trigger setParams + jest.advanceTimersByTime(PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + + expect(mockSetParams).toHaveBeenCalledWith({ + initialTab: 'perps', + shouldSelectPerpsTab: true, + }); + }); + + it('should handle first-time user on testnet', async () => { + // Mock first-time user on testnet + jest.mocked(selectIsFirstTimePerpsUser).mockReturnValue(true); + + await handlePerpsUrl({ perpsPath: 'perps' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.TUTORIAL, + params: { + isFromDeeplink: true, + }, + }); + }); + + it('should default to tutorial when state is undefined', async () => { + // Mock undefined state returning true (default) + jest.mocked(selectIsFirstTimePerpsUser).mockReturnValue(true); + + await handlePerpsUrl({ perpsPath: 'perps' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.TUTORIAL, + params: { + isFromDeeplink: true, + }, + }); + }); + + it('should fallback to markets list on error', async () => { + // Mock selector to return false (returning user) + jest.mocked(selectIsFirstTimePerpsUser).mockReturnValue(false); + + // Mock navigation.navigate to throw an error for the first call + mockNavigate.mockImplementationOnce(() => { + throw new Error('Navigation error'); + }); + + await handlePerpsUrl({ perpsPath: 'perps' }); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + }); + }); + + describe('handlePerpsAssetUrl', () => { + it('should navigate to market details with valid symbol', async () => { + await handlePerpsAssetUrl({ assetPath: '?symbol=BTC' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: expect.objectContaining({ + symbol: 'BTC', + name: 'BTC', + price: '0', + change24h: '0', + change24hPercent: '0', + volume24h: '0', + volume: '0', + fundingRate: 0, + openInterest: '0', + maxLeverage: '100', + logoUrl: '', + nextFundingTime: 0, + fundingIntervalHours: 8, + }), + }, + }); + }); + + it('should handle symbol without question mark', async () => { + await handlePerpsAssetUrl({ assetPath: 'symbol=ETH' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: expect.objectContaining({ + symbol: 'ETH', + name: 'ETH', + }), + }, + }); + }); + + it('should convert symbol to uppercase', async () => { + await handlePerpsAssetUrl({ assetPath: '?symbol=btc' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market: expect.objectContaining({ + symbol: 'BTC', + name: 'BTC', + }), + }, + }); + }); + + it('should navigate to markets list when no symbol provided', async () => { + await handlePerpsAssetUrl({ assetPath: '?' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + }); + + it('should navigate to markets list with empty assetPath', async () => { + await handlePerpsAssetUrl({ assetPath: '' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + }); + + it('should fallback to markets list on error', async () => { + // Mock error in navigation + mockNavigate.mockImplementationOnce(() => { + throw new Error('Navigation error'); + }); + + await handlePerpsAssetUrl({ assetPath: '?symbol=BTC' }); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + }); + + it('should handle malformed URL parameters gracefully', async () => { + await handlePerpsAssetUrl({ assetPath: '?invalid¶ms&here' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + }); + + it('should log debug messages during processing', async () => { + await handlePerpsAssetUrl({ assetPath: '?symbol=SOL' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePerpsAssetUrl] Starting with assetPath:', + '?symbol=SOL', + ); + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePerpsAssetUrl] Parsed symbol:', + 'SOL', + ); + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePerpsAssetUrl] Navigating directly to market details for:', + 'SOL', + ); + }); + }); +}); diff --git a/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts new file mode 100644 index 000000000000..aba1162cef8f --- /dev/null +++ b/app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts @@ -0,0 +1,154 @@ +import NavigationService from '../../NavigationService'; +import Routes from '../../../constants/navigation/Routes'; +import { PerpsMarketData } from '../../../components/UI/Perps/controllers/types'; +import DevLogger from '../../SDKConnect/utils/DevLogger'; +import { PERFORMANCE_CONFIG } from '../../../components/UI/Perps/constants/perpsConfig'; +import { store } from '../../../store'; +import { selectIsFirstTimePerpsUser } from '../../../components/UI/Perps/selectors/perpsController'; + +interface HandlePerpsUrlParams { + perpsPath: string; +} + +interface HandlePerpsAssetUrlParams { + assetPath: string; +} + +/** + * Handles deeplinks for the perps market details + * Priority #1: Main perps deeplink + * + * @param params Object containing the perps path + * + * URL format: https://link.metamask.io/perps or https://link.metamask.io/perps-markets + * + * Behavior: + * - First-time users: Navigate to tutorial + * - Returning users: Navigate to markets list + */ +export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { + DevLogger.log( + '[handlePerpsUrl] Starting perps deeplink handling with path:', + perpsPath, + ); + try { + // Check if user is first-time perps user using selector + const isFirstTime = selectIsFirstTimePerpsUser(store.getState()); + DevLogger.log('[handlePerpsUrl] isFirstTimeUser:', isFirstTime); + + if (isFirstTime) { + DevLogger.log( + '[handlePerpsUrl] Navigating to tutorial with isFromDeeplink: true', + ); + // Navigate to tutorial for first-time users + NavigationService.navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.TUTORIAL, + params: { + isFromDeeplink: true, + }, + }); + } else { + DevLogger.log( + '[handlePerpsUrl] Navigating to wallet home with Perps tab selected for returning user', + ); + + // Navigate to wallet home first + NavigationService.navigation.navigate(Routes.WALLET.HOME); + + // The timeout is REQUIRED - React Navigation needs time to: + // 1. Complete the navigation transition + // 2. Mount the Wallet component + // 3. Make navigation context available for setParams + // Without this delay, the tab selection will fail + setTimeout(() => { + NavigationService.navigation.setParams({ + initialTab: 'perps', + shouldSelectPerpsTab: true, + }); + }, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS); + } + } catch (error) { + DevLogger.log('Failed to handle perps deeplink:', error); + // Fallback to markets list on error + NavigationService.navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + } +}; + +/** + * Handles deeplinks for specific perps assets + * Priority #2: Asset-specific deeplink + * + * @param params Object containing the asset path + * + * URL format: https://link.metamask.io/perps-asset?symbol=BTC + * + * Parameters: + * - symbol: The asset symbol (e.g., 'BTC', 'ETH') + * + * Behavior: + * - Navigate directly to the specific asset's market details + * - Falls back to markets list if asset not found + */ +export const handlePerpsAssetUrl = async ({ + assetPath, +}: HandlePerpsAssetUrlParams) => { + DevLogger.log('[handlePerpsAssetUrl] Starting with assetPath:', assetPath); + try { + // Parse URL parameters + const cleanPath = assetPath.startsWith('?') + ? assetPath.slice(1) + : assetPath; + const urlParams = new URLSearchParams(cleanPath); + const symbol = urlParams.get('symbol'); + + DevLogger.log('[handlePerpsAssetUrl] Parsed symbol:', symbol); + + if (!symbol) { + // No symbol provided, navigate to markets list + DevLogger.log( + '[handlePerpsAssetUrl] No symbol provided, navigating to markets list', + ); + NavigationService.navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + return; + } + + // Create a minimal market object with just the symbol + // The PerpsMarketDetailsView will fetch the full data + const market = { + symbol: symbol.toUpperCase(), + name: symbol.toUpperCase(), + price: '0', + change24h: '0', + change24hPercent: '0', + volume24h: '0', + volume: '0', + fundingRate: 0, + openInterest: '0', + maxLeverage: '100', + logoUrl: '', + nextFundingTime: 0, + fundingIntervalHours: 8, + } as PerpsMarketData; + + DevLogger.log( + '[handlePerpsAssetUrl] Navigating directly to market details for:', + symbol, + ); + NavigationService.navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKET_DETAILS, + params: { + market, + }, + }); + } catch (error) { + DevLogger.log('Failed to handle perps asset deeplink:', error); + // Fallback to markets list on error + NavigationService.navigation.navigate(Routes.PERPS.ROOT, { + screen: Routes.PERPS.MARKETS, + }); + } +}; diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts index 17daf296b4a0..9efe5ac0f084 100644 --- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts @@ -43,6 +43,9 @@ describe('handleUniversalLinks', () => { const mockHandleBrowserUrl = jest.fn(); const mockHandleOpenHome = jest.fn(); const mockHandleSwap = jest.fn(); + const mockHandleCreateAccount = jest.fn(); + const mockHandlePerps = jest.fn(); + const mockHandlePerpsAsset = jest.fn(); const mockConnectToChannel = jest.fn(); const mockGetConnections = jest.fn(); const mockRevalidateChannel = jest.fn(); @@ -61,6 +64,9 @@ describe('handleUniversalLinks', () => { _handleBrowserUrl: mockHandleBrowserUrl, _handleOpenHome: mockHandleOpenHome, _handleSwap: mockHandleSwap, + _handleCreateAccount: mockHandleCreateAccount, + _handlePerps: mockHandlePerps, + _handlePerpsAsset: mockHandlePerpsAsset, } as unknown as DeeplinkManager; const handled = jest.fn(); @@ -383,6 +389,102 @@ describe('handleUniversalLinks', () => { ); }); + describe('ACTIONS.CREATE_ACCOUNT', () => { + it('calls _handleCreateAccount when action is CREATE_ACCOUNT', async () => { + const createAccountUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.CREATE_ACCOUNT}/some-account-path`; + const createAccountUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: createAccountUrl, + pathname: `/${ACTIONS.CREATE_ACCOUNT}/some-account-path`, + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: createAccountUrlObj, + browserCallBack: mockBrowserCallBack, + url: createAccountUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandleCreateAccount).toHaveBeenCalledWith( + '/some-account-path', + ); + }); + }); + + describe('ACTIONS.PERPS', () => { + it('calls _handlePerps when action is PERPS', async () => { + const perpsUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PERPS}/markets`; + const perpsUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: perpsUrl, + pathname: `/${ACTIONS.PERPS}/markets`, + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: perpsUrlObj, + browserCallBack: mockBrowserCallBack, + url: perpsUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePerps).toHaveBeenCalledWith('/markets'); + }); + + it('calls _handlePerps when action is PERPS_MARKETS', async () => { + const perpsMarketsUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PERPS_MARKETS}`; + const perpsMarketsUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: perpsMarketsUrl, + pathname: `/${ACTIONS.PERPS_MARKETS}`, + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: perpsMarketsUrlObj, + browserCallBack: mockBrowserCallBack, + url: perpsMarketsUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePerps).toHaveBeenCalledWith(''); + }); + }); + + describe('ACTIONS.PERPS_ASSET', () => { + it('calls _handlePerpsAsset when action is PERPS_ASSET', async () => { + const perpsAssetUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PERPS_ASSET}/BTC`; + const perpsAssetUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: perpsAssetUrl, + pathname: `/${ACTIONS.PERPS_ASSET}/BTC`, + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: perpsAssetUrlObj, + browserCallBack: mockBrowserCallBack, + url: perpsAssetUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePerpsAsset).toHaveBeenCalledWith('/BTC'); + }); + }); + describe('signature verification', () => { beforeEach(() => { DevLogger.log = jest.fn(); diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts index c35147fa514c..4ee4a2922aa5 100644 --- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts @@ -30,6 +30,9 @@ enum SUPPORTED_ACTIONS { SWAP = ACTIONS.SWAP, SEND = ACTIONS.SEND, CREATE_ACCOUNT = ACTIONS.CREATE_ACCOUNT, + PERPS = ACTIONS.PERPS, + PERPS_MARKETS = ACTIONS.PERPS_MARKETS, + PERPS_ASSET = ACTIONS.PERPS_ASSET, } async function handleUniversalLink({ @@ -115,8 +118,8 @@ async function handleUniversalLink({ }; const shouldProceed = await new Promise((resolve) => { - const [, action] = validatedUrl.pathname.split('/'); - const sanitizedAction = action?.replace(/-/g, ' '); + const [, actionName] = validatedUrl.pathname.split('/'); + const sanitizedAction = actionName?.replace(/-/g, ' '); const pageTitle: string = capitalize(sanitizedAction?.toLowerCase()) || ''; handleDeepLinkModalDisplay({ @@ -169,6 +172,15 @@ async function handleUniversalLink({ } else if (action === SUPPORTED_ACTIONS.CREATE_ACCOUNT) { const deeplinkUrl = urlObj.href.replace(BASE_URL_ACTION, ''); instance._handleCreateAccount(deeplinkUrl); + } else if ( + action === SUPPORTED_ACTIONS.PERPS || + action === SUPPORTED_ACTIONS.PERPS_MARKETS + ) { + const perpsPath = urlObj.href.replace(BASE_URL_ACTION, ''); + instance._handlePerps(perpsPath); + } else if (action === SUPPORTED_ACTIONS.PERPS_ASSET) { + const assetPath = urlObj.href.replace(BASE_URL_ACTION, ''); + instance._handlePerpsAsset(assetPath); } } diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts index df6a4140495e..ea05ff34e2be 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts @@ -9,6 +9,12 @@ import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler'; import { OAuthErrorType, OAuthError } from '../../error'; import Logger from '../../../../util/Logger'; +const ACM_ERRORS_REGEX = { + CANCEL: /cancel/i, + NO_CREDENTIAL: /no credential/i, + NO_MATCHING_CREDENTIAL: /matching credential/i, +}; + /** * AndroidGoogleLoginHandler is the login handler for the Google login on android. */ @@ -71,11 +77,23 @@ export class AndroidGoogleLoginHandler extends BaseLoginHandler { if (error instanceof OAuthError) { throw error; } else if (error instanceof Error) { - if (error.message.toLowerCase().includes('cancel')) { + if (ACM_ERRORS_REGEX.CANCEL.test(error.message)) { throw new OAuthError( 'handleGoogleLogin: User cancelled the login process', OAuthErrorType.UserCancelled, ); + } else if (ACM_ERRORS_REGEX.NO_CREDENTIAL.test(error.message)) { + throw new OAuthError( + 'handleGoogleLogin: Google login has no credential', + OAuthErrorType.GoogleLoginNoCredential, + ); + } else if ( + ACM_ERRORS_REGEX.NO_MATCHING_CREDENTIAL.test(error.message) + ) { + throw new OAuthError( + 'handleGoogleLogin: Google login has no matching credential', + OAuthErrorType.GoogleLoginNoMatchingCredential, + ); } else { throw new OAuthError(error, OAuthErrorType.UnknownError); } diff --git a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts index b6f6889c9343..01457fd94301 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/index.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/index.test.ts @@ -462,6 +462,44 @@ describe('OAuth login handlers', () => { } }); + // no credentials + it('should throw GoogleLoginNoCredential when no credentials are found', async () => { + const message = 'e1 error Mo.m: No credential available'; + mockSignInWithGoogle.mockRejectedValue(new Error(message)); + + const handler = createLoginHandler('android', AuthConnection.Google); + try { + await handler.login(); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe( + OAuthErrorType.GoogleLoginNoCredential, + ); + expect((error as OAuthError).message).toContain( + `Google login has no credential - handleGoogleLogin: Google login has no credential`, + ); + } + }); + + it('should throw GoogleLoginNoMatchingCredential when no matching credential is found', async () => { + const message = + 'During begin signin, failure response from one tap. 16: [28433] Cannot find matching credential error'; + mockSignInWithGoogle.mockRejectedValue(new Error(message)); + + const handler = createLoginHandler('android', AuthConnection.Google); + try { + await handler.login(); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe( + OAuthErrorType.GoogleLoginNoMatchingCredential, + ); + expect((error as OAuthError).message).toContain( + `Google login has no matching credential - handleGoogleLogin: Google login has no matching credential`, + ); + } + }); + it('should re-throw existing OAuthError instances', async () => { const existingError = new OAuthError( 'Test error', diff --git a/app/core/OAuthService/error.ts b/app/core/OAuthService/error.ts index 35fbe31f838d..0533d18b27ac 100644 --- a/app/core/OAuthService/error.ts +++ b/app/core/OAuthService/error.ts @@ -11,6 +11,8 @@ export enum OAuthErrorType { InvalidOauthStateError = 10010, GoogleLoginError = 10011, AppleLoginError = 10012, + GoogleLoginNoCredential = 10013, + GoogleLoginNoMatchingCredential = 10014, } export const OAuthErrorMessages: Record = { @@ -26,6 +28,9 @@ export const OAuthErrorMessages: Record = { [OAuthErrorType.InvalidOauthStateError]: 'Invalid OAuth state', [OAuthErrorType.GoogleLoginError]: 'Google login error', [OAuthErrorType.AppleLoginError]: 'Apple login error', + [OAuthErrorType.GoogleLoginNoCredential]: 'Google login has no credential', + [OAuthErrorType.GoogleLoginNoMatchingCredential]: + 'Google login has no matching credential', } as const; export class OAuthError extends Error { diff --git a/app/reducers/networkSelector/index.test.ts b/app/reducers/networkSelector/index.test.ts deleted file mode 100644 index 9e1eb2f83d08..000000000000 --- a/app/reducers/networkSelector/index.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import networkOnboardReducer, { initialState } from '.'; -import { SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID } from '../../actions/sendFlow'; - -type NetworkOnboardState = Omit & { - sendFlowChainId: string | null | undefined; -}; - -describe('networkOnboardReducer', () => { - it('returns the initial state when no action is provided', () => { - const state = networkOnboardReducer(undefined, {} as never); - expect(state).toEqual(initialState); - }); - - it('returns the initial state for unhandled action types', () => { - const action = { type: 'UNKNOWN_ACTION', payload: 'test' } as never; - const state = networkOnboardReducer(initialState, action); - expect(state).toEqual(initialState); - }); - - describe('SHOW_NETWORK_ONBOARDING action', () => { - it('handles SHOW_NETWORK_ONBOARDING action with all parameters', () => { - const action = { - type: 'SHOW_NETWORK_ONBOARDING', - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - networkState: { - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - }, - }); - }); - - it('handles SHOW_NETWORK_ONBOARDING action with partial parameters', () => { - const action = { - type: 'SHOW_NETWORK_ONBOARDING', - showNetworkOnboarding: false, - nativeToken: '', - networkType: '', - networkUrl: '', - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - networkState: { - showNetworkOnboarding: false, - nativeToken: '', - networkType: '', - networkUrl: '', - }, - }); - }); - - it('preserves other state properties when handling SHOW_NETWORK_ONBOARDING', () => { - const existingState = { - ...initialState, - networkOnboardedState: { '0x1': true }, - switchedNetwork: { - networkUrl: 'https://existing.network', - networkStatus: true, - }, - }; - - const action = { - type: 'SHOW_NETWORK_ONBOARDING', - showNetworkOnboarding: true, - nativeToken: 'MATIC', - networkType: 'polygon', - networkUrl: 'https://polygon-rpc.com', - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkOnboardedState).toEqual({ '0x1': true }); - expect(state.switchedNetwork).toEqual({ - networkUrl: 'https://existing.network', - networkStatus: true, - }); - }); - }); - - describe('NETWORK_SWITCHED action', () => { - it('handles NETWORK_SWITCHED action with true status', () => { - const action = { - type: 'NETWORK_SWITCHED', - networkUrl: 'https://polygon-rpc.com', - networkStatus: true, - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - switchedNetwork: { - networkUrl: 'https://polygon-rpc.com', - networkStatus: true, - }, - }); - }); - - it('handles NETWORK_SWITCHED action with false status', () => { - const action = { - type: 'NETWORK_SWITCHED', - networkUrl: 'https://failed-network.com', - networkStatus: false, - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - switchedNetwork: { - networkUrl: 'https://failed-network.com', - networkStatus: false, - }, - }); - }); - - it('preserves other state properties when handling NETWORK_SWITCHED', () => { - const existingState = { - ...initialState, - networkOnboardedState: { '0x89': true }, - networkState: { - showNetworkOnboarding: true, - nativeToken: 'MATIC', - networkType: 'polygon', - networkUrl: 'https://polygon.network', - }, - }; - - const action = { - type: 'NETWORK_SWITCHED', - networkUrl: 'https://new-network.com', - networkStatus: true, - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkOnboardedState).toEqual({ '0x89': true }); - expect(state.networkState).toEqual({ - showNetworkOnboarding: true, - nativeToken: 'MATIC', - networkType: 'polygon', - networkUrl: 'https://polygon.network', - }); - }); - }); - - describe('NETWORK_ONBOARDED action', () => { - it('handles NETWORK_ONBOARDED action with new network', () => { - const action = { - type: 'NETWORK_ONBOARDED', - payload: '0x89', - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - networkState: { - showNetworkOnboarding: false, - nativeToken: '', - networkType: '', - networkUrl: '', - }, - networkOnboardedState: { - '0x89': true, - }, - }); - }); - - it('adds to existing onboarded networks', () => { - const existingState = { - ...initialState, - networkOnboardedState: { - '0x1': true, - '0xa': true, - }, - }; - - const action = { - type: 'NETWORK_ONBOARDED', - payload: '0x89', - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkOnboardedState).toEqual({ - '0x1': true, - '0xa': true, - '0x89': true, - }); - }); - - it('can overwrite existing onboarded network', () => { - const existingState = { - ...initialState, - networkOnboardedState: { - '0x1': false, - }, - }; - - const action = { - type: 'NETWORK_ONBOARDED', - payload: '0x1', - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkOnboardedState).toEqual({ - '0x1': true, - }); - }); - - it('resets networkState when handling NETWORK_ONBOARDED', () => { - const existingState = { - ...initialState, - networkState: { - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - }, - }; - - const action = { - type: 'NETWORK_ONBOARDED', - payload: '0x1', - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkState).toEqual({ - showNetworkOnboarding: false, - nativeToken: '', - networkType: '', - networkUrl: '', - }); - }); - }); - - describe('SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID action', () => { - it('handles SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID action with chainId', () => { - const action = { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId: '0x1', - } as const; - - const state = networkOnboardReducer(initialState, action as never); - - expect(state).toEqual({ - ...initialState, - sendFlowChainId: '0x1', - }); - }); - - it('handles SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID action with null chainId', () => { - const existingState: NetworkOnboardState = { - ...initialState, - sendFlowChainId: '0x89', - }; - - const action = { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId: null, - } as const; - - const state = networkOnboardReducer( - existingState as typeof initialState, - action as never, - ); - - expect(state).toEqual({ - ...initialState, - sendFlowChainId: null, - }); - }); - - it('handles SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID action with undefined chainId', () => { - const existingState: NetworkOnboardState = { - ...initialState, - sendFlowChainId: '0x89', - }; - - const action = { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId: undefined, - } as const; - - const state = networkOnboardReducer( - existingState as typeof initialState, - action as never, - ); - - expect(state).toEqual({ - ...initialState, - sendFlowChainId: undefined, - }); - }); - - it('preserves other state properties when setting chainId', () => { - const existingState = { - ...initialState, - networkOnboardedState: { '0x1': true }, - networkState: { - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - }, - switchedNetwork: { - networkUrl: 'https://polygon.network', - networkStatus: true, - }, - }; - - const action = { - type: SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID, - chainId: '0xa', - } as const; - - const state = networkOnboardReducer(existingState, action as never); - - expect(state.networkOnboardedState).toEqual({ '0x1': true }); - expect(state.networkState).toEqual({ - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - }); - expect(state.switchedNetwork).toEqual({ - networkUrl: 'https://polygon.network', - networkStatus: true, - }); - expect(state.sendFlowChainId).toBe('0xa'); - }); - }); - - describe('Edge cases and default parameters', () => { - it('handles action with no parameters using defaults', () => { - const state = networkOnboardReducer(initialState, undefined as never); - expect(state).toEqual(initialState); - }); - - it('maintains immutability when updating state', () => { - const action = { - type: 'SHOW_NETWORK_ONBOARDING', - showNetworkOnboarding: true, - nativeToken: 'ETH', - networkType: 'mainnet', - networkUrl: 'https://mainnet.infura.io', - } as const; - - const stateBefore = { ...initialState }; - const stateAfter = networkOnboardReducer(initialState, action as never); - - expect(stateBefore).toEqual(initialState); - expect(stateAfter).not.toBe(initialState); - }); - }); -}); diff --git a/app/reducers/networkSelector/index.ts b/app/reducers/networkSelector/index.ts index b5c6480e6aed..f2acf21ceead 100644 --- a/app/reducers/networkSelector/index.ts +++ b/app/reducers/networkSelector/index.ts @@ -1,5 +1,3 @@ -import { SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID } from '../../actions/sendFlow'; - export const initialState = { networkOnboardedState: {}, networkState: { @@ -12,7 +10,6 @@ export const initialState = { networkUrl: '', networkStatus: false, }, - sendFlowChainId: null, }; /** @@ -30,7 +27,7 @@ function networkOnboardReducer( networkStatus: boolean; showNetworkOnboarding: boolean; type: string; - chainId?: string; + // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any payload: any; } = { @@ -40,7 +37,6 @@ function networkOnboardReducer( networkStatus: false, showNetworkOnboarding: false, type: '', - chainId: undefined, payload: undefined, }, ) { @@ -77,12 +73,6 @@ function networkOnboardReducer( [action.payload]: true, }, }; - case SET_TRANSACTION_SEND_FLOW_CONTEXTUAL_CHAIN_ID: { - return { - ...state, - sendFlowChainId: action.chainId, - }; - } default: return state; } diff --git a/app/selectors/accountTrackerController.test.ts b/app/selectors/accountTrackerController.test.ts index 0671b76d818f..53a219fe08e2 100644 --- a/app/selectors/accountTrackerController.test.ts +++ b/app/selectors/accountTrackerController.test.ts @@ -1,17 +1,13 @@ import { RpcEndpointType } from '@metamask/network-controller'; -import { SolScope } from '@metamask/keyring-api'; -import { AccountInformation } from '@metamask/assets-controllers'; import { MOCK_ACCOUNTS_CONTROLLER_STATE, MOCK_ADDRESS_2, } from '../util/test/accountsControllerTestUtils'; import { RootState } from '../reducers'; -import { - selectAccountBalanceByChainId, - selectAccountsByContextualChainId, -} from './accountTrackerController'; +import { selectAccountBalanceByChainId } from './accountTrackerController'; import { mockNetworkState } from '../util/test/network'; import mockedEngine from '../core/__mocks__/MockedEngine'; +import { SolScope } from '@metamask/keyring-api'; const MOCK_CHAIN_ID = '0x1'; @@ -89,69 +85,3 @@ describe('selectAccountBalanceByChainId', () => { expect(result).toBeUndefined(); }); }); - -describe('selectAccountsByContextualChainId', () => { - const mockAccountsByChainId = { - '0x1': { - [MOCK_ADDRESS_2]: { balance: '0x100' }, - '0xAccount2': { balance: '0x200' }, - }, - '0x5': { - [MOCK_ADDRESS_2]: { balance: '0x300' }, - '0xAccount3': { balance: '0x400' }, - }, - }; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns accounts for the contextual chain ID', () => { - const result = selectAccountsByContextualChainId.resultFunc( - mockAccountsByChainId, - '0x5', - '0x1', - ); - - expect(result).toEqual({ - [MOCK_ADDRESS_2]: { balance: '0x300' }, - '0xAccount3': { balance: '0x400' }, - }); - }); - - it('returns an empty object if no accounts exist for the contextual chain ID', () => { - const result = selectAccountsByContextualChainId.resultFunc( - mockAccountsByChainId, - '0xUnknownChain', - '0x1', - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object if accountsByChainId is undefined', () => { - const result = selectAccountsByContextualChainId.resultFunc( - undefined as unknown as Record< - string, - { [address: string]: AccountInformation } - >, - '0x1', - '0x1', - ); - - expect(result).toEqual({}); - }); - - it('falls back to chainId when contextual chain ID is undefined', () => { - const result = selectAccountsByContextualChainId.resultFunc( - mockAccountsByChainId, - undefined, - '0x1', - ); - - expect(result).toEqual({ - [MOCK_ADDRESS_2]: { balance: '0x100' }, - '0xAccount2': { balance: '0x200' }, - }); - }); -}); diff --git a/app/selectors/accountTrackerController.ts b/app/selectors/accountTrackerController.ts index 4b5127bc7ed9..fbf29e6c27dd 100644 --- a/app/selectors/accountTrackerController.ts +++ b/app/selectors/accountTrackerController.ts @@ -4,7 +4,6 @@ import { RootState } from '../reducers'; import { createDeepEqualSelector } from './util'; import { selectEvmChainId } from './networkController'; import { selectSelectedInternalAccountFormattedAddress } from './accountsController'; -import { selectSendFlowContextualChainId } from './sendFlow'; const selectAccountTrackerControllerState = (state: RootState) => state.engine.backgroundState.AccountTrackerController; @@ -40,9 +39,3 @@ export const selectAccountBalanceByChainId = createDeepEqualSelector( return accountsBalance; }, ); - -export const selectAccountsByContextualChainId = createDeepEqualSelector( - [selectAccountsByChainId, selectSendFlowContextualChainId, selectEvmChainId], - (accountsByChainId, contextualChainId, chainId) => - accountsByChainId?.[contextualChainId || chainId] || {}, -); diff --git a/app/selectors/networkInfos.test.ts b/app/selectors/networkInfos.test.ts deleted file mode 100644 index 9c6061a89e4a..000000000000 --- a/app/selectors/networkInfos.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { SolScope } from '@metamask/keyring-api'; -import { NetworkConfiguration } from '@metamask/network-controller'; -import { selectNetworkImageSourceByChainId } from './networkInfos'; -import { RootState } from '../reducers'; -import { getNetworkImageSource } from '../util/networks'; -import { getNonEvmNetworkImageSourceByChainId } from '../util/networks/customNetworks'; -import { - selectProviderConfig, - selectEvmNetworkConfigurationsByChainId, -} from './networkController'; -import { - selectIsEvmNetworkSelected, - selectSelectedNonEvmNetworkChainId, - selectSelectedNonEvmNetworkName, -} from './multichainNetworkController'; - -// Mock the utility functions -jest.mock('../util/networks', () => ({ - getNetworkNameFromProviderConfig: jest.fn(), - getNetworkImageSource: jest.fn(), -})); - -jest.mock('../util/networks/customNetworks', () => ({ - getNonEvmNetworkImageSourceByChainId: jest.fn(), -})); - -// Mock the network selectors directly -jest.mock('./networkController', () => ({ - selectProviderConfig: jest.fn(), - selectEvmNetworkConfigurationsByChainId: jest.fn(), -})); - -jest.mock('./multichainNetworkController', () => ({ - selectIsEvmNetworkSelected: jest.fn(), - selectSelectedNonEvmNetworkChainId: jest.fn(), - selectSelectedNonEvmNetworkName: jest.fn(), -})); - -describe('selectNetworkImageSourceByChainId', () => { - const mockGetNetworkImageSource = - getNetworkImageSource as jest.MockedFunction; - const mockGetNonEvmNetworkImageSourceByChainId = - getNonEvmNetworkImageSourceByChainId as jest.MockedFunction< - typeof getNonEvmNetworkImageSourceByChainId - >; - const mockSelectEvmNetworkConfigurationsByChainId = - selectEvmNetworkConfigurationsByChainId as jest.MockedFunction< - typeof selectEvmNetworkConfigurationsByChainId - >; - const mockSelectIsEvmNetworkSelected = - selectIsEvmNetworkSelected as jest.MockedFunction< - typeof selectIsEvmNetworkSelected - >; - const mockSelectSelectedNonEvmNetworkChainId = - selectSelectedNonEvmNetworkChainId as jest.MockedFunction< - typeof selectSelectedNonEvmNetworkChainId - >; - const mockSelectProviderConfig = selectProviderConfig as jest.MockedFunction< - typeof selectProviderConfig - >; - const mockSelectSelectedNonEvmNetworkName = - selectSelectedNonEvmNetworkName as jest.MockedFunction< - typeof selectSelectedNonEvmNetworkName - >; - - // Mock state - simplified since we're mocking selectors directly - let mockState: RootState; - - beforeEach(() => { - jest.clearAllMocks(); - // Clear reselect cache to prevent memoization issues between tests - selectNetworkImageSourceByChainId.clearCache?.(); - // Create a unique state object for each test to avoid reselect memoization - mockState = { test: Math.random() } as unknown as RootState; - }); - - describe('when EVM network is selected', () => { - beforeEach(() => { - // Set up mocks for EVM network selection - mockSelectIsEvmNetworkSelected.mockReturnValue(true); - mockSelectSelectedNonEvmNetworkChainId.mockReturnValue(SolScope.Mainnet); - mockSelectProviderConfig.mockReturnValue({ - chainId: '0x1', - ticker: 'ETH', - rpcPrefs: { blockExplorerUrl: 'https://etherscan.io' }, - type: 'infura', - id: 'infura-mainnet', - nickname: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }); - mockSelectSelectedNonEvmNetworkName.mockReturnValue('Solana Mainnet'); - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({ - '0x1': { - chainId: '0x1', - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'infura-mainnet', - type: 'infura', - url: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }, - ], - blockExplorerUrls: ['https://etherscan.io'], - } as NetworkConfiguration, - '0x89': { - chainId: '0x89', - name: 'Polygon', - nativeCurrency: 'MATIC', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [ - { - networkClientId: 'polygon-mainnet', - type: 'custom', - url: 'https://polygon-rpc.com', - }, - ], - blockExplorerUrls: ['https://polygonscan.com'], - } as NetworkConfiguration, - }); - }); - - it('should return network image source for existing EVM chain ID', () => { - const chainId = '0x1'; - const expectedImageSource = { uri: 'ethereum-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'infura', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - - it('should use custom network type for chain ID with custom RPC endpoint', () => { - const chainId = '0x89'; - const expectedImageSource = { uri: 'polygon-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - - it('should default to custom network type when network configuration is undefined', () => { - const chainId = '0x999'; - const expectedImageSource = { uri: 'custom-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - }); - - describe('when non-EVM network is selected', () => { - beforeEach(() => { - // Set up mocks specific to non-EVM tests - mockSelectIsEvmNetworkSelected.mockReturnValue(false); - mockSelectSelectedNonEvmNetworkChainId.mockReturnValue(SolScope.Mainnet); - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({}); - mockSelectProviderConfig.mockReturnValue({ - chainId: '0x1', - ticker: 'ETH', - rpcPrefs: { blockExplorerUrl: 'https://etherscan.io' }, - type: 'infura', - id: 'infura-mainnet', - nickname: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }); - mockSelectSelectedNonEvmNetworkName.mockReturnValue('Solana Mainnet'); - }); - - it('should return non-EVM network image source', () => { - const chainId = '0x1'; // This should be ignored when non-EVM is selected - const expectedImageSource = { uri: 'solana-logo' }; - - mockGetNonEvmNetworkImageSourceByChainId.mockReturnValue( - expectedImageSource, - ); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNonEvmNetworkImageSourceByChainId).toHaveBeenCalledWith( - SolScope.Mainnet, - ); - expect(mockGetNetworkImageSource).not.toHaveBeenCalled(); - expect(result).toBe(expectedImageSource); - }); - }); - - describe('edge cases with EVM networks', () => { - beforeEach(() => { - // Set up mocks for EVM network edge cases - mockSelectIsEvmNetworkSelected.mockReturnValue(true); - mockSelectSelectedNonEvmNetworkChainId.mockReturnValue(SolScope.Mainnet); - mockSelectProviderConfig.mockReturnValue({ - chainId: '0x1', - ticker: 'ETH', - rpcPrefs: { blockExplorerUrl: 'https://etherscan.io' }, - type: 'infura', - id: 'infura-mainnet', - nickname: 'Ethereum Mainnet', - rpcUrl: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }); - mockSelectSelectedNonEvmNetworkName.mockReturnValue('Solana Mainnet'); - }); - - it('should default to custom network type when network configuration has no RPC endpoints', () => { - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({ - '0xabc': { - chainId: '0xabc', - name: 'Unknown Network', - nativeCurrency: 'UNK', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [], - blockExplorerUrls: [], - } as NetworkConfiguration, - }); - - const chainId = '0xabc'; - const expectedImageSource = { uri: 'custom-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - - it('should handle network configuration with empty rpcEndpoints', () => { - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({ - '0xdef': { - chainId: '0xdef', - name: 'Null RPC Network', - nativeCurrency: 'NULL', - defaultRpcEndpointIndex: 0, - rpcEndpoints: [], - blockExplorerUrls: [], - } as NetworkConfiguration, - }); - - const chainId = '0xdef'; - const expectedImageSource = { uri: 'custom-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - - it('should handle empty chain ID parameter', () => { - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({}); - - const chainId = ''; - const expectedImageSource = { uri: 'default-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId: '', - }); - expect(result).toBe(expectedImageSource); - }); - - it('should handle malformed chain ID parameter', () => { - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({}); - - const chainId = 'invalid-chain-id'; - const expectedImageSource = { uri: 'default-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId: 'invalid-chain-id', - }); - expect(result).toBe(expectedImageSource); - }); - - it('should handle state where networkConfigurationsByChainId is empty object', () => { - mockSelectEvmNetworkConfigurationsByChainId.mockReturnValue({}); - - const chainId = '0x1'; - const expectedImageSource = { uri: 'default-logo' }; - - mockGetNetworkImageSource.mockReturnValue(expectedImageSource); - - const result = selectNetworkImageSourceByChainId(mockState, chainId); - - expect(mockGetNetworkImageSource).toHaveBeenCalledWith({ - networkType: 'custom', - chainId, - }); - expect(result).toBe(expectedImageSource); - }); - }); -}); diff --git a/app/selectors/networkInfos.ts b/app/selectors/networkInfos.ts index d78f00750fae..5507e3454620 100644 --- a/app/selectors/networkInfos.ts +++ b/app/selectors/networkInfos.ts @@ -1,7 +1,6 @@ import { createSelector } from 'reselect'; import { CaipChainId, Hex } from '@metamask/utils'; import { RootState } from '../reducers'; -import { getNonEvmNetworkImageSourceByChainId } from '../util/networks/customNetworks'; import { getNetworkNameFromProviderConfig, getNetworkImageSource, @@ -16,6 +15,7 @@ import { selectSelectedNonEvmNetworkChainId, selectSelectedNonEvmNetworkName, } from './multichainNetworkController'; +import { getNonEvmNetworkImageSourceByChainId } from '../util/networks/customNetworks'; export const selectEvmNetworkName = createSelector( selectProviderConfig, diff --git a/app/selectors/sendFlow/index.test.ts b/app/selectors/sendFlow/index.test.ts deleted file mode 100644 index 82ed5c62d9b1..000000000000 --- a/app/selectors/sendFlow/index.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { RootState } from '../../reducers'; -import initialRootState from '../../util/test/initial-root-state'; -import { selectSendFlowContextualChainId } from './index'; - -describe('sendFlow selectors', () => { - const initialState: RootState = { - ...initialRootState, - networkOnboarded: { - sendFlowChainId: '0x1', - }, - }; - describe('selectSendFlowContextualChainId', () => { - it('should return contextual chain ID when present', () => { - const result = selectSendFlowContextualChainId(initialState); - expect(result).toBe('0x1'); - }); - - it('should return null when contextual chain ID is not set', () => { - const state = { - ...initialState, - networkOnboarded: { - sendFlowChainId: null, - }, - }; - - const result = selectSendFlowContextualChainId( - state as unknown as RootState, - ); - expect(result).toBeNull(); - }); - - it('should return undefined when networkOnboarded is not present', () => { - const state = { - ...initialState, - networkOnboarded: { - sendFlowChainId: undefined, - }, - }; - - const result = selectSendFlowContextualChainId(state); - expect(result).toBeUndefined(); - }); - - it('should return undefined when state is null', () => { - const result = selectSendFlowContextualChainId( - null as unknown as RootState, - ); - expect(result).toBeUndefined(); - }); - - it('should return undefined when state is undefined', () => { - const result = selectSendFlowContextualChainId( - undefined as unknown as RootState, - ); - expect(result).toBeUndefined(); - }); - - it('should handle different chain ID formats', () => { - const testCases = [ - { chainId: '0x1', expected: '0x1' }, - { chainId: '0xa86a', expected: '0xa86a' }, - { chainId: '0xaa36a7', expected: '0xaa36a7' }, - { chainId: '', expected: '' }, - ]; - - testCases.forEach(({ chainId, expected }) => { - const state = { - ...initialState, - networkOnboarded: { - sendFlowChainId: chainId, - }, - }; - - const result = selectSendFlowContextualChainId(state); - expect(result).toBe(expected); - }); - }); - }); -}); diff --git a/app/selectors/sendFlow/index.ts b/app/selectors/sendFlow/index.ts deleted file mode 100644 index 80750d6cbb87..000000000000 --- a/app/selectors/sendFlow/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RootState } from '../../reducers'; - -export const selectSendFlowContextualChainId = (state: RootState) => - state?.networkOnboarded?.sendFlowChainId; diff --git a/app/selectors/tokenBalancesController.test.ts b/app/selectors/tokenBalancesController.test.ts index b3cb7cdc377a..652316046468 100644 --- a/app/selectors/tokenBalancesController.test.ts +++ b/app/selectors/tokenBalancesController.test.ts @@ -3,7 +3,6 @@ import { cloneDeep } from 'lodash'; import { RootState } from '../reducers'; import { selectContractBalances, - selectContractBalancesByContextualChainId, selectAllTokenBalances, selectTokensBalances, selectAddressHasTokenBalances, @@ -54,9 +53,6 @@ describe('TokenBalancesController Selectors', () => { }, }, }, - networkOnboarded: { - sendFlowChainId: '0x5', - }, } as unknown as RootState; describe('selectContractBalances', () => { @@ -116,62 +112,6 @@ describe('TokenBalancesController Selectors', () => { }); }); - describe('selectContractBalancesByContextualChainId', () => { - it('returns token balances for the selected account and contextual chain ID', () => { - const selectedAccount: Hex = '0xAccount1'; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({ - '0xToken3': '0x300', - }); - }); - - it('returns an empty object if no balances exist for the selected account', () => { - const selectedAccount: Hex = '0xUnknownAccount'; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object if no balances exist for the contextual chain ID', () => { - const selectedAccount: Hex = '0xAccount1'; - const contextualChainId = '0xUnknownChain'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object if the selected account is undefined', () => { - const selectedAccount: string = ''; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - }); - describe('selectAllTokenBalances', () => { it('returns all token balances', () => { const result = selectAllTokenBalances(mockRootState); @@ -352,60 +292,4 @@ describe('TokenBalancesController Selectors', () => { expect(result2).toBe(result4); }); }); - - describe('selectContractBalancesByContextualChainId', () => { - it('returns token balances for the selected account and contextual chain ID', () => { - const selectedAccount: Hex = '0xAccount1'; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({ - '0xToken3': '0x300', - }); - }); - - it('returns an empty object if no balances exist for the selected account', () => { - const selectedAccount: Hex = '0xUnknownAccount'; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object if no balances exist for the contextual chain ID', () => { - const selectedAccount: Hex = '0xAccount1'; - const contextualChainId = '0xUnknownChain'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - - it('returns an empty object if the selected account is undefined', () => { - const selectedAccount: string = ''; - const contextualChainId = '0x5'; - - const result = selectContractBalancesByContextualChainId.resultFunc( - mockTokenBalancesControllerState, - selectedAccount, - contextualChainId, - ); - - expect(result).toEqual({}); - }); - }); }); diff --git a/app/selectors/tokenBalancesController.ts b/app/selectors/tokenBalancesController.ts index d11b1fb4dd52..9a74e89d53ce 100644 --- a/app/selectors/tokenBalancesController.ts +++ b/app/selectors/tokenBalancesController.ts @@ -8,7 +8,6 @@ import { selectEvmChainId } from './networkController'; import { createDeepEqualSelector } from './util'; import { selectShowFiatInTestnets } from './settings'; import { isTestNet } from '../util/networks'; -import { selectSendFlowContextualChainId } from './sendFlow'; const selectTokenBalancesControllerState = (state: RootState) => state.engine.backgroundState.TokenBalancesController; @@ -124,17 +123,3 @@ export const selectAddressHasTokenBalances = createDeepEqualSelector( return false; }, ); - -export const selectContractBalancesByContextualChainId = createSelector( - selectTokenBalancesControllerState, - selectSelectedInternalAccountAddress, - selectSendFlowContextualChainId, - ( - tokenBalancesControllerState: TokenBalancesControllerState, - selectedInternalAccountAddress: string | undefined, - contextualChainId: string, - ) => - tokenBalancesControllerState.tokenBalances?.[ - selectedInternalAccountAddress as Hex - ]?.[contextualChainId as Hex] ?? {}, -); diff --git a/app/util/dapp-url-list.js b/app/util/dapp-url-list.js index cfb9ddab8d40..99e7907edf70 100644 --- a/app/util/dapp-url-list.js +++ b/app/util/dapp-url-list.js @@ -31,6 +31,10 @@ export default [ url: 'https://breaker.io/', name: 'Breaker', }, + { + url: 'https://card.metamask.io/', + name: 'MetaMask Card', + }, { url: 'https://beta.cent.co', name: 'Cent', diff --git a/locales/languages/en.json b/locales/languages/en.json index 51d31f9f4aaf..62a99149c347 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -5205,6 +5205,11 @@ "user_cancelled_title": "Login cancelled", "user_cancelled_description": "You cancelled the login process.\nTry again when you’re ready.", "user_cancelled_button": "Try again", + + "google_login_no_credential_title": "Google login failed", + "google_login_no_credential_description": "We couldn’t find a Google account associated with this login. Try again with a different login method.", + "google_login_no_credential_button": "Try again", + "oauth_error_title": "Login failed", "oauth_error_description": "An error occurred while logging in.\nTry again and if the issue continues, contact MetaMask Support.", "oauth_error_button": "Try again" diff --git a/package.json b/package.json index 21047d968464..dd060aa7fec7 100644 --- a/package.json +++ b/package.json @@ -228,7 +228,7 @@ "@metamask/design-system-twrnc-preset": "^0.2.1", "@metamask/design-tokens": "^8.1.1", "@metamask/earn-controller": "^5.0.0", - "@metamask/eip1193-permission-middleware": "^0.1.0", + "@metamask/eip1193-permission-middleware": "^1.0.0", "@metamask/error-reporting-service": "^2.0.0", "@metamask/eth-hd-keyring": "^12.1.0", "@metamask/eth-json-rpc-filters": "^9.0.0", @@ -256,12 +256,12 @@ "@metamask/mobile-wallet-protocol-wallet-client": "^0.0.6", "@metamask/multichain-account-service": "^0.4.0", "@metamask/multichain-api-client": "^0.6.5", - "@metamask/multichain-api-middleware": "^0.4.0", + "@metamask/multichain-api-middleware": "^1.0.0", "@metamask/multichain-network-controller": "^0.10.0", "@metamask/multichain-transactions-controller": "^2.0.0", "@metamask/network-controller": "^24.0.0", "@metamask/network-enablement-controller": "0.1.0", - "@metamask/notification-services-controller": "^11.0.0", + "@metamask/notification-services-controller": "^16.0.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^13.1.0", "@metamask/post-message-stream": "^10.0.0", @@ -290,7 +290,7 @@ "@metamask/snaps-rpc-methods": "^13.5.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/snaps-utils": "^11.5.0", - "@metamask/solana-wallet-snap": "^2.3.1", + "@metamask/solana-wallet-snap": "^2.3.2", "@metamask/solana-wallet-standard": "^0.5.1", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 79e8e260102f..19f04bd0a0e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4899,11 +4899,6 @@ "@metamask/controller-utils" "^11.9.0" "@metamask/utils" "^11.2.0" -"@metamask/api-specs@^0.10.12": - version "0.10.17" - resolved "https://registry.yarnpkg.com/@metamask/api-specs/-/api-specs-0.10.17.tgz#e6974ac38e9ae80f67adfd3b4919322d926e5085" - integrity sha512-NCUlmDBLL8Cif5cfYXOgKdU56ZSpWnsbNnCUJUehPnNOyWuADx9blYhb1jdN3gWQwCpCOLawpFRzXsfM0FebaQ== - "@metamask/api-specs@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@metamask/api-specs/-/api-specs-0.14.0.tgz#806f8327d262001dca5e60946f45cc579164a3e2" @@ -5055,40 +5050,14 @@ "@metamask/utils" "^8.2.0" "@types/eslint" "^8.44.7" -"@metamask/chain-agnostic-permission@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@metamask/chain-agnostic-permission/-/chain-agnostic-permission-0.1.0.tgz#ce9125a919d801a17698279f9ee4a76c640a0054" - integrity sha512-0BtSXda+Z/uMoZOxWcUe+t9XvZPOPbXjsbYa8GNBwr+qdQjPwezrKCElZW+zEB3oddkValmfQ8UPuBavI2ebHg== - dependencies: - "@metamask/api-specs" "^0.10.12" - "@metamask/controller-utils" "^11.6.0" - "@metamask/network-controller" "^22.2.1" - "@metamask/permission-controller" "^11.0.6" - "@metamask/rpc-errors" "^7.0.2" - "@metamask/utils" "^11.2.0" - lodash "^4.17.21" - -"@metamask/chain-agnostic-permission@^0.7.0": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@metamask/chain-agnostic-permission/-/chain-agnostic-permission-0.7.1.tgz#9f2b45c21fe1447f33fd7c3007db91a860483c9e" - integrity sha512-q2Hro95cgHkXPAsKMaWRaH8C6sGudsNLHqlG/ym62qbImx4Wjjh0abHMHjJN4TbIvDoyWqNZxYeCeUgKnfynIA== - dependencies: - "@metamask/api-specs" "^0.14.0" - "@metamask/controller-utils" "^11.10.0" - "@metamask/network-controller" "^23.6.0" - "@metamask/permission-controller" "^11.0.6" - "@metamask/rpc-errors" "^7.0.2" - "@metamask/utils" "^11.2.0" - lodash "^4.17.21" - -"@metamask/chain-agnostic-permission@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@metamask/chain-agnostic-permission/-/chain-agnostic-permission-1.1.0.tgz#1d92fd4fe23e54e92c3e8079cf54d47a09ebd204" - integrity sha512-xr5y8sXn/pCgwJbbhK3VvIrB/Xejvsnr3So9iPnlJ7to3EDxLi/8g0VOqFXwghoKkSGZLCmobaf7lJlZ+hpUvw== +"@metamask/chain-agnostic-permission@^1.0.0", "@metamask/chain-agnostic-permission@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@metamask/chain-agnostic-permission/-/chain-agnostic-permission-1.1.1.tgz#3769399b727a468ebd8c1359205c6690acb2c333" + integrity sha512-qyhClL7ZFGwuOR1+R0uasTAxUgLbVxtHiQ/mb/7/ZQLZYGWofVnxwaIE4Sh1n6VsGnYDZQJTXyiF6F4Kp2czew== dependencies: "@metamask/api-specs" "^0.14.0" - "@metamask/controller-utils" "^11.11.0" - "@metamask/network-controller" "^24.0.1" + "@metamask/controller-utils" "^11.12.0" + "@metamask/network-controller" "^24.1.0" "@metamask/permission-controller" "^11.0.6" "@metamask/rpc-errors" "^7.0.2" "@metamask/utils" "^11.4.2" @@ -5154,25 +5123,18 @@ "@metamask/stake-sdk" "^3.2.1" reselect "^5.1.1" -"@metamask/eip1193-permission-middleware@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@metamask/eip1193-permission-middleware/-/eip1193-permission-middleware-0.1.0.tgz#9a8e1a1f89317d50df9063e184ce7ae0e8a48093" - integrity sha512-SRkvC8vGRB1VxDhr1l5WyAu3Xia9N2tvyJzrGNIMmkmh3okmZVqzvoj4tZy8gUVuIBJXQe79SmsWAiQRBM+eKw== +"@metamask/eip1193-permission-middleware@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@metamask/eip1193-permission-middleware/-/eip1193-permission-middleware-1.0.0.tgz#263a1875105c756c8a7e6bc659d34f6ade38d8f1" + integrity sha512-Oau6mS450Yy2ONH2XdQCJE6kpJEQ7gfVHLNTdFAV7X4BuT0hCTh0AtHrKm85UUUON0XqeBg/TAPaNxFg57qjsw== dependencies: - "@metamask/chain-agnostic-permission" "^0.1.0" - "@metamask/controller-utils" "^11.6.0" + "@metamask/chain-agnostic-permission" "^1.0.0" + "@metamask/controller-utils" "^11.10.0" "@metamask/json-rpc-engine" "^10.0.3" "@metamask/permission-controller" "^11.0.6" "@metamask/utils" "^11.2.0" lodash "^4.17.21" -"@metamask/error-reporting-service@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@metamask/error-reporting-service/-/error-reporting-service-1.0.0.tgz#73c943152750a95f0b9c384bcb77cc2f7a4e3405" - integrity sha512-mmVOy8kB/NqmITUuEzoJCvjvXQND1B54micN0Muz2bHnfrsc45vfJXCx5/STtfvPfWK4eNoVopWpSKpJANQ1zg== - dependencies: - "@metamask/base-controller" "^8.0.1" - "@metamask/error-reporting-service@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@metamask/error-reporting-service/-/error-reporting-service-2.0.0.tgz#1a840150d1468bfb8779b77209f2acbbd31ae602" @@ -5190,17 +5152,6 @@ resolved "https://registry.yarnpkg.com/@metamask/eslint-plugin-design-tokens/-/eslint-plugin-design-tokens-1.1.0.tgz#c9d4471f04f62bfb307aa261d11b7a674eb27961" integrity sha512-33BJTEl96wXrkNdydNgTnfz3C0XP6/FdYbKzNnM8wT3XeVOkF/EOt8oiY8X4stXlNKDJtKyQchxENgZwS8sl9w== -"@metamask/eth-block-tracker@^11.0.3", "@metamask/eth-block-tracker@^11.0.4": - version "11.0.4" - resolved "https://registry.yarnpkg.com/@metamask/eth-block-tracker/-/eth-block-tracker-11.0.4.tgz#20fc468c9ed6d8d61da514184e546a9faee5fa64" - integrity sha512-t/em7d7lmV6FqU/4bPRaImhYQPp7ZXy2mYzh/3FocYGAhSOqjL107uqLb5lds8EdIp1rqO4Hm+NgNhgKI8yhIw== - dependencies: - "@metamask/eth-json-rpc-provider" "^4.1.5" - "@metamask/safe-event-emitter" "^3.1.1" - "@metamask/utils" "^11.0.1" - json-rpc-random-id "^1.0.1" - pify "^5.0.0" - "@metamask/eth-block-tracker@^12.0.0", "@metamask/eth-block-tracker@^12.0.1": version "12.0.1" resolved "https://registry.yarnpkg.com/@metamask/eth-block-tracker/-/eth-block-tracker-12.0.1.tgz#dfd9c4624ec12810f9035eb30ec3af80c38d234b" @@ -5235,7 +5186,7 @@ async-mutex "^0.5.0" pify "^5.0.0" -"@metamask/eth-json-rpc-infura@^10.0.0", "@metamask/eth-json-rpc-infura@^10.2.0": +"@metamask/eth-json-rpc-infura@^10.2.0": version "10.2.0" resolved "https://registry.yarnpkg.com/@metamask/eth-json-rpc-infura/-/eth-json-rpc-infura-10.2.0.tgz#f3fe76f5dee6b799c51c3b4637417bf9d2eb986f" integrity sha512-I3sJvIqqJT2GgEBNmPqTE8EGXiYf44d5JOenjiY65q56yuzPmnnCa/uuRR9tImHqsiE1Nhmikj0RYjrXfFhMlw== @@ -5245,24 +5196,6 @@ "@metamask/rpc-errors" "^7.0.2" "@metamask/utils" "^11.0.1" -"@metamask/eth-json-rpc-middleware@^15.0.1": - version "15.3.0" - resolved "https://registry.yarnpkg.com/@metamask/eth-json-rpc-middleware/-/eth-json-rpc-middleware-15.3.0.tgz#0c73b686cc13aa6a4a18d7867573c1b26714a1f9" - integrity sha512-pEinhvCmEYi+7tYEjqP4gxU1H7cYzBtO9/UNQAn+df9zVuYGJoJj7PSV39pfrjXkg90UsVt9uHxblY68/nunxw== - dependencies: - "@metamask/eth-block-tracker" "^11.0.4" - "@metamask/eth-json-rpc-provider" "^4.1.7" - "@metamask/eth-sig-util" "^8.1.2" - "@metamask/json-rpc-engine" "^10.0.2" - "@metamask/rpc-errors" "^7.0.2" - "@metamask/superstruct" "^3.1.0" - "@metamask/utils" "^11.1.0" - "@types/bn.js" "^5.1.5" - bn.js "^5.2.1" - klona "^2.0.6" - pify "^5.0.0" - safe-stable-stringify "^2.4.3" - "@metamask/eth-json-rpc-middleware@^17.0.1": version "17.0.1" resolved "https://registry.yarnpkg.com/@metamask/eth-json-rpc-middleware/-/eth-json-rpc-middleware-17.0.1.tgz#d453716c1b2d2ead070ecfc92fa69cacedb99c78" @@ -5642,16 +5575,16 @@ resolved "https://registry.yarnpkg.com/@metamask/multichain-api-client/-/multichain-api-client-0.6.5.tgz#b269eec6b484e7ca9a43088fbcb454334cacb242" integrity sha512-DP8uZZLrmZwvMr/RvMqymS4IdQW37Nv2ZUqX90/f5JdTzqePVhMMdyrc14+BTZpYV2RNu7flhcDXa7gmTvJPbw== -"@metamask/multichain-api-middleware@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@metamask/multichain-api-middleware/-/multichain-api-middleware-0.4.0.tgz#81c64322de53d4c61eb047b1f683c4e17c16194a" - integrity sha512-wMYu/xqVojsZkIod9ztby992M7nldUgA4KTrbfUvZ1WsbioggieBiGkL/XRbkune49fH3ywKlTvRRi0Yqtuapw== +"@metamask/multichain-api-middleware@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@metamask/multichain-api-middleware/-/multichain-api-middleware-1.0.0.tgz#6a4165bcb4502ac0e1be52e90e32751cc090a1a5" + integrity sha512-JTbOIn7RvSB7v9+uHYpZFngqX+KswpbVORNxRxt06z1lpi7bgkVE8F5jZwpKy/jl0/zibGmvAhK9I7IXsQ8hgQ== dependencies: "@metamask/api-specs" "^0.14.0" - "@metamask/chain-agnostic-permission" "^0.7.0" - "@metamask/controller-utils" "^11.9.0" + "@metamask/chain-agnostic-permission" "^1.0.0" + "@metamask/controller-utils" "^11.10.0" "@metamask/json-rpc-engine" "^10.0.3" - "@metamask/network-controller" "^23.5.0" + "@metamask/network-controller" "^24.0.0" "@metamask/permission-controller" "^11.0.6" "@metamask/rpc-errors" "^7.0.2" "@metamask/utils" "^11.2.0" @@ -5704,62 +5637,13 @@ immer "^9.0.6" uuid "^8.3.2" -"@metamask/network-controller@^22.2.1": - version "22.2.1" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-22.2.1.tgz#6141c6c1ac26be1e78381f1d77804030e0e769ea" - integrity sha512-jqfhqWcZgoLxCQqc890YwlCOKB+SlKqGEWKfJA3gZ3f8fuHSXCzIgin0vw5MLPtFTXCMUSuTTbohtXjedbaqIQ== - dependencies: - "@metamask/base-controller" "^8.0.0" - "@metamask/controller-utils" "^11.5.0" - "@metamask/eth-block-tracker" "^11.0.3" - "@metamask/eth-json-rpc-infura" "^10.0.0" - "@metamask/eth-json-rpc-middleware" "^15.0.1" - "@metamask/eth-json-rpc-provider" "^4.1.8" - "@metamask/eth-query" "^4.0.0" - "@metamask/json-rpc-engine" "^10.0.3" - "@metamask/rpc-errors" "^7.0.2" - "@metamask/swappable-obj-proxy" "^2.3.0" - "@metamask/utils" "^11.1.0" - async-mutex "^0.5.0" - fast-deep-equal "^3.1.3" - immer "^9.0.6" - loglevel "^1.8.1" - reselect "^5.1.1" - uri-js "^4.4.1" - uuid "^8.3.2" - -"@metamask/network-controller@^23.5.0", "@metamask/network-controller@^23.6.0": - version "23.6.0" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-23.6.0.tgz#b07805c6f6cfb2d44c05f2a151dbff4dc9b2e0bc" - integrity sha512-oAuqOn0+qd48qUSIQnPoRgfakW05oJDux+OmIXlJQao8iiWN2vQWrvt8xCbgg3l08Srhr2u3unrsKz6DcmJgbA== - dependencies: - "@metamask/base-controller" "^8.0.1" - "@metamask/controller-utils" "^11.10.0" - "@metamask/error-reporting-service" "^1.0.0" - "@metamask/eth-block-tracker" "^12.0.1" - "@metamask/eth-json-rpc-infura" "^10.2.0" - "@metamask/eth-json-rpc-middleware" "^17.0.1" - "@metamask/eth-json-rpc-provider" "^4.1.8" - "@metamask/eth-query" "^4.0.0" - "@metamask/json-rpc-engine" "^10.0.3" - "@metamask/rpc-errors" "^7.0.2" - "@metamask/swappable-obj-proxy" "^2.3.0" - "@metamask/utils" "^11.2.0" - async-mutex "^0.5.0" - fast-deep-equal "^3.1.3" - immer "^9.0.6" - loglevel "^1.8.1" - reselect "^5.1.1" - uri-js "^4.4.1" - uuid "^8.3.2" - -"@metamask/network-controller@^24.0.0", "@metamask/network-controller@^24.0.1": - version "24.0.1" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-24.0.1.tgz#92c07c948622b65230e7b515db88a7f50dab12d1" - integrity sha512-xi6EQjxfgZGFHBJogmQ9xhSldCT/7rTJYnitU1qhLnavRlx/zgNtG5Z8gr2g3AdYRRYgCdhjQcnOdCww6P3Kng== +"@metamask/network-controller@^24.0.0", "@metamask/network-controller@^24.1.0": + version "24.1.0" + resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-24.1.0.tgz#4d2dd9ddfb06ee274456c05a4b0e935a7b803c4b" + integrity sha512-sgMVPAOVDu6tg9BL/In8W7Fs8isWGmY08gW+JJcQr6h2x5oTjJfATg2zwucxgxb++CV37IbnvBviyjF4y16o0Q== dependencies: - "@metamask/base-controller" "^8.0.1" - "@metamask/controller-utils" "^11.11.0" + "@metamask/base-controller" "^8.1.0" + "@metamask/controller-utils" "^11.12.0" "@metamask/eth-block-tracker" "^12.0.1" "@metamask/eth-json-rpc-infura" "^10.2.0" "@metamask/eth-json-rpc-middleware" "^17.0.1" @@ -5795,15 +5679,15 @@ "@ethersproject/providers" "^5.7.2" async-mutex "^0.3.1" -"@metamask/notification-services-controller@^11.0.0": - version "11.0.0" - resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-11.0.0.tgz#b6cb08371630a5bb977a1b611ea6e28e4b3f5fa4" - integrity sha512-NYnoM4VK8bSyfPfX4U4XTKhhZgS6LZ3cO89ED7RyUJHdHcQPq06y05ie3QbJq7cS8UmexwfhjJPNDK5TVImjVg== +"@metamask/notification-services-controller@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-16.0.0.tgz#b22e2cb0dbb62cb5012b375398bf14b71d399ad3" + integrity sha512-kdIlerqGxYRViH/dAX2Ka7KQmGWwZ0WhLCIHr3+1jIQqe6h4J8hq5/Kxlch3EJPQBNbbC+4FU5Ue8nqbIywTNQ== dependencies: "@contentful/rich-text-html-renderer" "^16.5.2" "@metamask/base-controller" "^8.0.1" - "@metamask/controller-utils" "^11.10.0" - "@metamask/utils" "^11.2.0" + "@metamask/controller-utils" "^11.11.0" + "@metamask/utils" "^11.4.2" bignumber.js "^9.1.2" firebase "^11.2.0" loglevel "^1.8.1" @@ -6298,10 +6182,10 @@ ses "^1.12.0" validate-npm-package-name "^5.0.0" -"@metamask/solana-wallet-snap@^2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.1.tgz#e65de7edec3edc1a9828d32f28d45cc1086d24a5" - integrity sha512-fG63PD6g6ja2+VI1nxtIMrNlzM7Jv3UPXLyv24EtBk/36wHVBV1emKQNAaJz4fNtMbSfmr6t0pZ+VVWGJ8xRZA== +"@metamask/solana-wallet-snap@^2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.2.tgz#80fb0b198133837c8b5077b588bbee61ace411b7" + integrity sha512-RC+oSsr23Tes4TKNlEcHd1ZmsUbj1beOyhdz67W54D1yfXcByrzIaIYt6BdlenhBGDrZwYsht7a5LgKhxkcgXw== "@metamask/solana-wallet-standard@^0.5.1": version "0.5.1"