From 093c3ab4640f8ecd4ef35e5b4ab39a314fe99417 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 26 Aug 2025 13:56:23 +0200 Subject: [PATCH 1/9] fix: cp-7.54.0 solana ws lifecycle (#18759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR bumps the Solana snap. The latest version includes a fix on the WebSockets lifecycle. Before, we were always opening connections onStart/onInstall/onUpdate, we were opening connections all the time. Now we open them only if client is active. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 21047d96846..1c182be58ae 100644 --- a/package.json +++ b/package.json @@ -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 79e8e260102..207ca83af63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6298,10 +6298,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" From d792de4820e25a6fc2f9e2af21760cb1c669def2 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Tue, 26 Aug 2025 06:19:53 -0600 Subject: [PATCH 2/9] chore: revert contextual chain id prs (#18487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** A contextual chain selector was introduced in PR #13938 to prepare for the removal of the global network selector to allow users to select networks specifically within the send flow context, moving away from the global network selector pattern. However, after extensive internal testing, we discovered regressions that impacted the user experience in the send flow. **Solution:** Remove the contextual chain selector implementation and rely on the original global network selector instead within the send flow. This ensures users can reliably select networks and send assets without encountering the identified regressions. Revert PRs: https://github.com/MetaMask/metamask-mobile/pull/13938 - Send flow with contextual chain selector https://github.com/MetaMask/metamask-mobile/pull/18042 - Selectors for the contextual network selector https://github.com/MetaMask/metamask-mobile/pull/18012 - Redux infrastructure for contextual chain ID for send flow ## **Changelog** CHANGELOG entry: Reverted contextual chain selector in send flow, restored global network selector ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/18186 https://github.com/MetaMask/metamask-mobile/issues/18180 https://github.com/MetaMask/metamask-mobile/issues/18172 ## **Manual testing steps** ```gherkin Feature: Send Flow Network Selection Scenario: User sends assets using global network selector Given the user opens the send flow When the user wants to send assets on different networks Then they should have access to the global network selector And they can select their preferred network globally And the selected network applies across the entire application And they can successfully send assets ``` ## **Screenshots/Recordings** Overall Flow https://github.com/user-attachments/assets/c4de553a-8295-4ab3-bfd6-649fca1715fe Fixes: Send from token details does not switch network in the send flow #18186 https://github.com/user-attachments/assets/1b4e3e02-c4a1-46a3-a8fa-0220ba007c3e Fixes: NFT issues in send flow #18180 https://github.com/user-attachments/assets/e151da0d-5b5a-4944-8c5f-7b8b7d9050fc Fixes: After adding a network from the send flow, send flow inconsistencies #18172 https://github.com/user-attachments/assets/ae5cb2bd-d46a-4168-878a-3b08e07122cd ### **Before** `~` ### **After** `~` ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Salim TOUBAL --- app/actions/sendFlow/index.test.ts | 53 - app/actions/sendFlow/index.ts | 11 - .../AccountFromToInfoCard.test.tsx | 37 - .../AccountFromToInfoCard.tsx | 19 +- .../AccountFromToInfoCard.types.tsx | 1 - .../AddressFrom.test.tsx | 415 ----- .../UI/AccountFromToInfoCard/AddressFrom.tsx | 133 +- .../AccountFromToInfoCard.test.tsx.snap | 2 +- .../__snapshots__/AddressFrom.test.tsx.snap | 1609 ----------------- app/components/UI/Navbar/index.js | 12 +- app/components/UI/Navbar/index.test.jsx | 1 + app/components/UI/NavbarTitle/index.js | 10 - .../UI/NetworkMainAssetLogo/index.js | 6 +- .../NetworkSelector/NetworkSelector.test.tsx | 187 -- .../Views/NetworkSelector/NetworkSelector.tsx | 27 +- .../NetworkSelector/useSwitchNetworks.ts | 29 +- .../Amount/__snapshots__/index.test.tsx.snap | 1025 ++--------- .../legacy/SendFlow/Amount/index.js | 442 +---- .../legacy/SendFlow/Amount/index.test.tsx | 915 ---------- .../Confirm/__snapshots__/index.test.tsx.snap | 2 +- .../legacy/SendFlow/Confirm/index.js | 139 +- .../legacy/SendFlow/Confirm/index.test.tsx | 577 +----- .../legacy/SendFlow/SendTo/index.js | 97 +- .../legacy/SendFlow/SendTo/index.test.tsx | 1456 --------------- .../legacy/SendFlow/SendTo/styles.ts | 36 +- .../ApproveTransactionReview/index.test.jsx | 60 - .../useAddressBalance.test.tsx | 383 +--- .../useAddressBalance/useAddressBalance.ts | 21 +- app/constants/networkSelector.test.ts | 218 --- app/reducers/networkSelector/index.test.ts | 372 ---- app/reducers/networkSelector/index.ts | 12 +- .../accountTrackerController.test.ts | 74 +- app/selectors/accountTrackerController.ts | 7 - app/selectors/networkInfos.test.ts | 326 ---- app/selectors/networkInfos.ts | 2 +- app/selectors/sendFlow/index.test.ts | 79 - app/selectors/sendFlow/index.ts | 4 - app/selectors/tokenBalancesController.test.ts | 116 -- app/selectors/tokenBalancesController.ts | 15 - 39 files changed, 302 insertions(+), 8628 deletions(-) delete mode 100644 app/actions/sendFlow/index.test.ts delete mode 100644 app/actions/sendFlow/index.ts delete mode 100644 app/components/UI/AccountFromToInfoCard/AddressFrom.test.tsx delete mode 100644 app/components/UI/AccountFromToInfoCard/__snapshots__/AddressFrom.test.tsx.snap delete mode 100644 app/constants/networkSelector.test.ts delete mode 100644 app/reducers/networkSelector/index.test.ts delete mode 100644 app/selectors/networkInfos.test.ts delete mode 100644 app/selectors/sendFlow/index.test.ts delete mode 100644 app/selectors/sendFlow/index.ts diff --git a/app/actions/sendFlow/index.test.ts b/app/actions/sendFlow/index.test.ts deleted file mode 100644 index 21ea20b4c00..00000000000 --- 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 074362e6279..00000000000 --- 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/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx index 10a3f12ee60..50f04461c1c 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx @@ -15,7 +15,6 @@ import { RootState } from '../../../reducers'; import { AssetsContractController } from '@metamask/assets-controllers'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { MOCK_KEYRING_CONTROLLER_STATE } from '../../../util/test/keyringControllerTestUtils'; -import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; const MOCK_ADDRESS_1 = '0xe64dD0AB5ad7e8C5F2bf6Ce75C34e187af8b920A'; const MOCK_ADDRESS_2 = '0x519d2CE57898513F676a5C3b66496c3C394c9CC7'; @@ -59,35 +58,6 @@ const mockInitialState: DeepPartial = { ...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 b2aa6645b54..85fe53586e7 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 243f1cc6251..17deaa4ec8e 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 d01d3a4b9a8..00000000000 --- 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 bf7d42baf1f..0f215fbbf1c 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 5def7b0a681..21419520df3 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 83884852f4c..d7ae463d463 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 } /> ), diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index be20ea66329..9d0803818d6 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 beb20ac6ef3..f61163a08fc 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 f2c07aabe2e..abfce750a73 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/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index fa1e217fb53..74b752510c7 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 e830dc571b0..70ec119795e 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 2cd14c4a276..3f80e36b1aa 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/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap b/app/components/Views/confirmations/legacy/SendFlow/Amount/__snapshots__/index.test.tsx.snap index 72ebd00da5b..c806e085ab3 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 7b4d3688136..b08e01921fb 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 1e8cd8f66c4..abb05a5e289 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 22a848b6716..a247bc3c044 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 1b21d783c3c..3d36c5fe54d 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 0237a8c14b9..ed529e0aba4 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 ed34b37acfd..88c7c39b698 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 8b3dfbafd2f..78316787907 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 253f0441588..69513d403dd 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 6de7c5f79ce..49a8d00fb89 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 44702d87244..e22f3ee784d 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/networkSelector.test.ts b/app/constants/networkSelector.test.ts deleted file mode 100644 index 7cd9730bbff..00000000000 --- 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/reducers/networkSelector/index.test.ts b/app/reducers/networkSelector/index.test.ts deleted file mode 100644 index 9e1eb2f83d0..00000000000 --- 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 b5c6480e6ae..f2acf21ceea 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 0671b76d818..53a219fe08e 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 4b5127bc7ed..fbf29e6c27d 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 9c6061a89e4..00000000000 --- 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 d78f00750fa..5507e345462 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 82ed5c62d9b..00000000000 --- 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 80750d6cbb8..00000000000 --- 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 b3cb7cdc377..65231604646 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 d11b1fb4dd5..9a74e89d53c 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] ?? {}, -); From 73d97a5cb0dd977539c35f98535e81377f991370 Mon Sep 17 00:00:00 2001 From: ieow <4881057+ieow@users.noreply.github.com> Date: Tue, 26 Aug 2025 21:28:17 +0800 Subject: [PATCH 3/9] fix: deescalate google error cp-7.54.0 (#18745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** De escalate google login error - no credential Android credential manager throw no credential when there are no account sign in on device. Or occasionally not able to find matching account during first login and able to login afterwards. This is not blocking error but the error is then throw as sentry capture error which would cause anxiety to user. The pr de-escalate with bottom sheet error and allow user to try to login again with same or other method This Pr also fixed the issue #18597 as it change the regex checking method. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/18556 https://github.com/MetaMask/metamask-mobile/issues/18597 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user try login google via android when there is no account signin in android Given user's android device do not have any google account When user try to login via google Then user should not be prompt with sentry error. bottom sheet error should be prompted ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/185d367f-63c8-4b11-a4c9-6942a25b276f ### **After** https://github.com/user-attachments/assets/af8cb600-d89f-48f6-8be8-f3a85f71f29d ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Views/Onboarding/index.js | 29 +++- .../Views/Onboarding/index.test.tsx | 164 +++++++++++++----- .../androidHandlers/google.ts | 20 ++- .../OAuthLoginHandlers/index.test.ts | 38 ++++ app/core/OAuthService/error.ts | 5 + locales/languages/en.json | 5 + 6 files changed, 206 insertions(+), 55 deletions(-) diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index 6508abf1263..e27d075dddd 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 1204e0fbbd9..a9364240cc3 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/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts index df6a4140495..ea05ff34e2b 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 b6f6889c934..01457fd9430 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 35fbe31f838..0533d18b27a 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/locales/languages/en.json b/locales/languages/en.json index 51d31f9f4aa..62a99149c34 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" From f9a15406dba8aa8d07e5ef0df35d09e08fab196c Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 26 Aug 2025 10:35:03 -0300 Subject: [PATCH 4/9] chore(card): add metamask card website on dapps-url-list.js file (#18744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds card.metamask.io to the dapps-url-list file, ensuring it appears in the MetaMask Mobile internal browser as one of the recommended websites. ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** simulator_screenshot_0339B22A-1890-4EB3-8F60-AA7E40F59A58 ### **After** simulator_screenshot_8B7463C4-3F1B-4598-8BDB-953E67C70BCB ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/util/dapp-url-list.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/util/dapp-url-list.js b/app/util/dapp-url-list.js index cfb9ddab8d4..99e7907edf7 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', From e6589d3b68d38ad10a534e2624bbc5e58b698341 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Tue, 26 Aug 2025 21:41:46 +0800 Subject: [PATCH 5/9] feat: multichain accounts connected list (#18721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR adds a new component `MultichainAccountConnectedList` for the bip44 dapp connection flow. It is currently not wired up and cannot be used. The only way to test is to use storybook. ## **Changelog** CHANGELOG entry: null ## **Related issues** Related to: https://consensyssoftware.atlassian.net/browse/MUL-669?atlOrigin=eyJpIjoiYzNhZGVkYWFhMGUyNDA2Zjg3N2EyZmVjZjJkNWM4YjIiLCJwIjoiaiJ9 ## **Manual testing steps** It is only testable on storybook. ## **Screenshots/Recordings** ### **After** Simulator Screenshot - iPhone 15
Pro - 2025-08-25 at 20 52 16 Simulator Screenshot - iPhone 15
Pro - 2025-08-25 at 20 52 22 Simulator Screenshot - iPhone 15
Pro - 2025-08-25 at 20 52 27 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .storybook/storybook.requires.js | 1 + ...ultichainAccountsConnectedList.stories.tsx | 191 ++++++++++++++++++ .../MultichainAccountsConnectedList.styles.ts | 68 +++++++ .../MultichainAccountsConnectedList.test.tsx | 172 ++++++++++++++++ .../MultichainAccountsConnectedList.tsx | 84 ++++++++ 5 files changed, 516 insertions(+) create mode 100644 app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx create mode 100644 app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.styles.ts create mode 100644 app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.test.tsx create mode 100644 app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.tsx diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index da4416ec12b..3463e1d0e2d 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/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx b/app/components/Views/MultichainAccounts/MultichainAccountsConnectedList/MultichainAccountsConnectedList.stories.tsx new file mode 100644 index 00000000000..b435c3273a8 --- /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 00000000000..fddac55d8e6 --- /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 00000000000..4cddacd5c28 --- /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 00000000000..fceb32c636b --- /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; From 5ad26d15dd7a84d37001e203714bad67ea2d7d8a Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:18:41 +0100 Subject: [PATCH 6/9] chore(deps): bump eip1193-permission, multichain-api, and notification-services controllers (#18726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates several MetaMask controller packages. ---
@metamask/eip1193-permission-middleware (from ^0.1.0^1.0.0) - [View Changelog](https://github.com/MetaMask/core/blob/main/packages/eip1193-permission-middleware/CHANGELOG.md#100) - **No major breaking changes** - Package is now marked as stable. - Additional dependency bumps (already in use in client): - `@metamask/controller-utils` → `^11.12.0` - `@metamask/utils` → `^11.4.2`
---
@metamask/multichain-api-middleware (from ^0.4.0^1.0.0) - [View Changelog](https://github.com/MetaMask/core/blob/main/packages/multichain-api-middleware/CHANGELOG.md#100) - **No major breaking changes** - Package is now marked as stable. - Additional dependency bumps (already in use in client): - `@metamask/multichain-transactions-controller` → `^2.0.0` - `@metamask/controller-utils` → `^11.10.0` - `@metamask/chain-agnostic-permission` -> `^1.0.0` - `@metamask/network-controller` -> `^24.0.0`
---
@metamask/notification-services-controller (from ^11.0.0^16.0.0) - [View Changelog](https://github.com/MetaMask/core/blob/main/packages/notification-services-controller/CHANGELOG.md#1600) - **No major breaking changes** - Peer dependency bump: - `@metamask/profile-sync-controller@^23.0.0` (already in use in client) → resolves peer dependency warning
## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/16555, https://github.com/MetaMask/metamask-mobile/issues/16556, https://github.com/MetaMask/metamask-mobile/issues/17741 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- package.json | 6 +- yarn.lock | 180 +++++++++------------------------------------------ 2 files changed, 35 insertions(+), 151 deletions(-) diff --git a/package.json b/package.json index 1c182be58ae..dd060aa7fec 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", diff --git a/yarn.lock b/yarn.lock index 207ca83af63..19f04bd0a0e 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" From afdab253a765e5100d2b5dc5ae657a0809020080 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Tue, 26 Aug 2025 23:16:02 +0800 Subject: [PATCH 7/9] refactor: enable multiselect in MultichainAccountSelectorList (#18719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR refactors the `MultichainAccountSelectorLIst` to accept a list of selected accounts. There are no UI changes. ## **Changelog** CHANGELOG entry: null ## **Related issues** ## **Manual testing steps** ```gherkin Feature: enable multiselect in MultichainAccountSelector Scenario: user goes to account list Given multichain account state 2 is enabled When user opens the account list Then sees that one account is selected. ``` ## **Screenshots/Recordings** There are no behavioural changes in the account list. ### **After** https://github.com/user-attachments/assets/d0eeb263-0690-48fa-9718-0cfe456f3179 ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../MultichainAccountSelectorList.stories.tsx | 39 ++++++++++++++----- .../MultichainAccountSelectorList.test.tsx | 30 +++++++------- .../MultichainAccountSelectorList.tsx | 16 +++++--- .../MultichainAccountSelectorList.types.ts | 2 +- .../Views/AccountSelector/AccountSelector.tsx | 2 +- 5 files changed, 56 insertions(+), 33 deletions(-) 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 3b3626e6f16..0ef7b2e9ca1 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 21f81d1a373..f604c0d50fa 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 d42b249948e..0cebc49b7e4 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 ( { {isMultichainAccountsState2Enabled && selectedAccountGroup ? ( ) : ( From 7e4b249aafe069d4b736a7ef59cbb6970ef5318b Mon Sep 17 00:00:00 2001 From: Andre Pimenta Date: Tue, 26 Aug 2025 16:34:27 +0100 Subject: [PATCH 8/9] fix: Fix network switcher not appearing on legacy Swaps (#18643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces a network selector on the legacy Swap screen so that it is possible to switch networks. ## **Changelog** CHANGELOG entry: Added network selector for the legacy Swaps screen ## **Related issues** Fixes #18241 - Swap (legacy) on any other network except Ethereum Mainnet is not possible ## **Manual testing steps** ```gherkin Feature: Network selector on legacy Swaps screen Scenario: user opens legacy Swaps screen When user opens legacy Swaps screen Then user is able to switch network ``` ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/ba50cc0b-9b6b-4958-9da1-6b38416ba8da ### **After** https://github.com/user-attachments/assets/4401af7e-e29a-4980-8918-1919c2ef3d0c ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/UI/Navbar/index.js | 7 ++++- app/components/UI/Swaps/index.js | 49 ++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index d7ae463d463..58b2edebdfa 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1651,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/Swaps/index.js b/app/components/UI/Swaps/index.js index 6442ef65567..c4433d3517d 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) => ({ From bc12958b76677908f0419ec0732106d6bc1f15df Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 27 Aug 2025 00:47:26 +0800 Subject: [PATCH 9/9] feat(perps): add deeplink support for perps (#18568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR implements deeplink support for the Perps (perpetual futures) feature, enabling direct navigation to Perps markets from external sources like marketing campaigns, notifications, and social media. The implementation supports two main deeplink types: 1. **Perps Market Overview**: Routes users to the main Perps tab (with tutorial flow for first-time users) 2. **Specific Asset Details**: Routes users directly to a specific perps asset (e.g., BTC, ETH) ## **Changelog** CHANGELOG entry: Added deeplink support for Perps markets, allowing direct navigation to Perps tab and specific asset details ## **Related issues** Fixes: TAT-1344 ## **Manual testing steps** ### Testing Deeplinks #### iOS Testing ```bash # Test Perps market overview deeplink xcrun simctl openurl booted "https://link-test.metamask.io/perps" # Test specific asset deeplinks xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=BTC" xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=ETH" xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=SOL" ``` #### Android Testing ```bash # Test Perps market overview deeplink adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps" io.metamask.debug # Test specific asset deeplinks adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps-asset?symbol=BTC" io.metamask.debug adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps-asset?symbol=ETH" io.metamask.debug ``` https://github.com/user-attachments/assets/d391fca1-be2a-4d65-96b2-2c5f2ef2ae34 ### Resetting First-Time User State To test the tutorial flow for first-time users, you need to reset the Perps state: 1. **Via Redux DevTools (Development builds):** - Open the app with Redux DevTools enabled - Navigate to Redux state - Find `engine.backgroundState.PerpsController` - Set `isFirstTimeUser: { testnet: true, mainnet: true }` 2. **Via Settings Menu:** - Go to Settings → Advanced → Reset Account - This will reset all account data including Perps state ### Test Scenarios ```gherkin Feature: Perps Deeplinks Navigation Scenario: First-time user navigates via perps deeplink Given the user has MetaMask Mobile installed And the user has never used Perps before (reset state if needed) When user clicks on deeplink "https://link-test.metamask.io/perps" Then the app opens to the Perps Tutorial screen And after completing or skipping the tutorial, user sees the Perps tab selected Scenario: Returning user navigates via perps deeplink Given the user has MetaMask Mobile installed And the user has completed the Perps tutorial When user clicks on deeplink "https://link-test.metamask.io/perps" Then the app opens directly to the Wallet home with Perps tab selected Scenario: User navigates to specific BTC asset via deeplink Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=BTC" Then the app opens directly to the BTC perps market details screen Scenario: User navigates to specific ETH asset via deeplink Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=ETH" Then the app opens directly to the ETH perps market details screen Scenario: User navigates with invalid asset symbol Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=INVALID" Then the app opens to the Perps tab (fallback behavior) Scenario: Tutorial skip navigates correctly from deeplink Given the user is a first-time Perps user And the user arrived via deeplink When user skips the tutorial Then the app navigates to Wallet home with Perps tab selected And the user can see the Perps markets list ``` ### Expected Results 1. **First-time users**: Should see the tutorial carousel with 6 steps, then navigate to Perps tab 2. **Returning users**: Should navigate directly to Wallet home with Perps tab selected 3. **Asset deeplinks**: Should open the specific market details view 4. **Invalid symbols**: Should fallback to Perps markets list 5. **Tab selection**: The Perps tab should be visually selected and active after navigation ## **Screenshots/Recordings** ### **Before** - No deeplink support for Perps feature - Users had to manually navigate to Perps tab through the app ### **After** - Direct deeplink navigation to Perps markets - Support for specific asset deeplinks (BTC, ETH, SOL, etc.) - Smart routing based on user state (tutorial vs direct navigation) https://github.com/user-attachments/assets/28b55198-995a-4e55-abc1-6715e528d9ac ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../PerpsTutorialCarousel.test.tsx | 156 +++++- .../PerpsTutorialCarousel.tsx | 44 +- .../UI/Perps/constants/perpsConfig.ts | 5 + .../Perps/controllers/PerpsController.test.ts | 186 +++++++ .../UI/Perps/controllers/PerpsController.ts | 27 + .../Perps/hooks/usePerpsFirstTimeUser.test.ts | 43 +- .../UI/Perps/hooks/usePerpsFirstTimeUser.ts | 8 +- .../selectors/perpsController/index.test.ts | 134 +++++ .../Perps/selectors/perpsController/index.ts | 9 + .../ResetAccountModal/ResetAccountModal.tsx | 4 + app/components/Views/Wallet/index.test.tsx | 8 +- app/components/Views/Wallet/index.tsx | 473 ++++++++++-------- app/constants/deeplinks.ts | 6 + .../DeeplinkManager/DeeplinkManager.test.ts | 45 ++ app/core/DeeplinkManager/DeeplinkManager.ts | 14 + .../Handlers/handlePerpsUrl.test.ts | 233 +++++++++ .../Handlers/handlePerpsUrl.ts | 154 ++++++ .../ParseManager/handleUniversalLink.test.ts | 102 ++++ .../ParseManager/handleUniversalLink.ts | 16 +- 19 files changed, 1442 insertions(+), 225 deletions(-) create mode 100644 app/core/DeeplinkManager/Handlers/handlePerpsUrl.test.ts create mode 100644 app/core/DeeplinkManager/Handlers/handlePerpsUrl.ts diff --git a/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx b/app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx index 5b3b5f35c6c..a58e68ce63c 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 c3940e75768..398d7afaae6 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 e9619d18ceb..a4b0658d720 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 98c3a7702e8..0b92f9c8be5 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 60c9e2017fa..340988d3ebe 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 8a6b1f33ce7..57b0ae04485 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 dc8e895ffc2..bf224357833 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 01c417ef5a0..8f4a3ab9bb5 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 147af0598a0..91b38271ff7 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/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx b/app/components/Views/Settings/AdvancedSettings/ResetAccountModal/ResetAccountModal.tsx index fabbd9405bd..5f526719b9d 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 a8682facea0..c98e7159600 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 7a35e92f72e..f5bbd5e7112 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/constants/deeplinks.ts b/app/constants/deeplinks.ts index bbad455a47d..a7558cf7513 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/core/DeeplinkManager/DeeplinkManager.test.ts b/app/core/DeeplinkManager/DeeplinkManager.test.ts index a0865fc0982..ea33da291e0 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 99d2df0f36e..84e88754901 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 00000000000..823491885cf --- /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 00000000000..aba1162cef8 --- /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 17daf296b4a..9efe5ac0f08 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 c35147fa514..4ee4a2922aa 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); } }