From 9c55522bd1f6ae174d0fe5004c2eb88d9eb700cb Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Thu, 18 Dec 2025 09:35:52 -0800 Subject: [PATCH 01/10] fix: remove unnecessary resolution and bump bridge controller (#24080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Remove unnecessary bridge-controller resolution and bump bridge controller ## **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. --- > [!NOTE] > Updates bridge and transaction controller packages (and lockfile) and removes an unnecessary bridge-controller resolution. > > - **Dependencies**: > - Bump `@metamask/bridge-controller` to `^64.2.0` in `dependencies` and lockfile. > - Bump `@metamask/bridge-status-controller` to `^64.2.0` in lockfile. > - Bump `@metamask/transaction-controller` to `62.7.0` in `resolutions`, `dependencies`, and lockfile. > - **Cleanup**: > - Remove obsolete `resolutions` entry for `@metamask/bridge-controller`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f2e82f4f5e9f303b17dac08f8c33aa746e3206f4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 7 +++---- yarn.lock | 42 +++++++++++++++++++++--------------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 33b7b176f5f..13c24b49715 100644 --- a/package.json +++ b/package.json @@ -176,8 +176,7 @@ "@ethereumjs/util@npm:^9.0.2": "patch:@ethereumjs/util@npm%3A9.1.0#~/.yarn/patches/@ethereumjs-util-npm-9.1.0-7e85509408.patch", "@metamask/key-tree@npm:^10.1.1": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/key-tree@npm:^10.0.2": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", - "@metamask/transaction-controller@npm:^62.6.0": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/bridge-controller@npm:^64.0.0": "patch:@metamask/bridge-controller@npm%3A61.0.0#~/.yarn/patches/@metamask-bridge-controller-npm-61.0.0-8c413c463f.patch" + "@metamask/transaction-controller@npm:^62.7.0": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -201,7 +200,7 @@ "@metamask/assets-controllers": "^94.1.0", "@metamask/base-controller": "^9.0.0", "@metamask/bitcoin-wallet-snap": "^1.8.0", - "@metamask/bridge-controller": "^64.1.0", + "@metamask/bridge-controller": "^64.2.0", "@metamask/bridge-status-controller": "^64.0.1", "@metamask/chain-agnostic-permission": "^1.3.0", "@metamask/composable-controller": "^12.0.0", @@ -289,7 +288,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^15.0.0", "@metamask/token-search-discovery-controller": "^4.0.0", - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", "@metamask/transaction-pay-controller": "^10.5.0", "@metamask/tron-wallet-snap": "^1.16.1", "@metamask/utils": "^11.8.1", diff --git a/yarn.lock b/yarn.lock index fbcb0d36c8e..e30f2c540f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7320,9 +7320,9 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^64.1.0": - version: 64.1.0 - resolution: "@metamask/bridge-controller@npm:64.1.0" +"@metamask/bridge-controller@npm:^64.1.0, @metamask/bridge-controller@npm:^64.2.0": + version: 64.2.0 + resolution: "@metamask/bridge-controller@npm:64.2.0" dependencies: "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" @@ -7330,7 +7330,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^35.0.0" - "@metamask/assets-controllers": "npm:^93.1.0" + "@metamask/assets-controllers": "npm:^94.1.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" @@ -7342,33 +7342,33 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.0" "@metamask/remote-feature-flag-controller": "npm:^3.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" - "@metamask/transaction-controller": "npm:^62.5.0" + "@metamask/transaction-controller": "npm:^62.7.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" reselect: "npm:^5.1.1" uuid: "npm:^8.3.2" - checksum: 10/b5019e54b79e89da5271b43309074ce43dc831dc01a5acc028c3acc9a8655f842d6d0b74092a0ddab9e4db3c622dd31280af6cedc179fdc0af970b7373ba4474 + checksum: 10/3669dca650e7b0424a55c852f1cb4f1c73a4e3e5554b1b1311f5ec9aa3e13eb4d752a90851b7d40de876bfdcc42b325d355d07fbeb6cee471bd362c0044d762b languageName: node linkType: hard "@metamask/bridge-status-controller@npm:^64.0.1, @metamask/bridge-status-controller@npm:^64.1.0": - version: 64.1.0 - resolution: "@metamask/bridge-status-controller@npm:64.1.0" + version: 64.2.0 + resolution: "@metamask/bridge-status-controller@npm:64.2.0" dependencies: "@metamask/accounts-controller": "npm:^35.0.0" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^64.1.0" + "@metamask/bridge-controller": "npm:^64.2.0" "@metamask/controller-utils": "npm:^11.16.0" "@metamask/gas-fee-controller": "npm:^26.0.0" "@metamask/network-controller": "npm:^27.0.0" "@metamask/polling-controller": "npm:^16.0.0" "@metamask/snaps-controllers": "npm:^14.0.1" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.5.0" + "@metamask/transaction-controller": "npm:^62.7.0" "@metamask/utils": "npm:^11.8.1" bignumber.js: "npm:^9.1.2" uuid: "npm:^8.3.2" - checksum: 10/b7445e9cd0997b3ef46e71003f608705281d38a0ad710aa5aaeac69915f738b70f7d73b66d37501f66d28c1ae03fccbf703863e9dd24383d78cac10805a9d9cc + checksum: 10/f707ea4ba3d52e2231025e24a8923121881fa303a4d1ab40ee5405fb62fa791bef0271ae4117afe60936dd4e8326815acd4b3806ea46869ba9dab91c43ce1d9d languageName: node linkType: hard @@ -9469,9 +9469,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:62.6.0, @metamask/transaction-controller@npm:^62.4.0, @metamask/transaction-controller@npm:^62.5.0": - version: 62.6.0 - resolution: "@metamask/transaction-controller@npm:62.6.0" +"@metamask/transaction-controller@npm:62.7.0, @metamask/transaction-controller@npm:^62.4.0, @metamask/transaction-controller@npm:^62.6.0": + version: 62.7.0 + resolution: "@metamask/transaction-controller@npm:62.7.0" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9503,7 +9503,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/d02731b018ee575dd9a8ca3529f9296cda51e9bf939628c7846c37a4cc024fdf41960393489c203c30c09730c68016be2c98619fcd60aaa3f24db7921069fc00 + checksum: 10/f9b34194b4e9bf775f66256da6fe0908854346da348238d122856a3bae3621e6ccafab273ed6c4f2b175848a2d74f0257a9f98a6efc6ff14f19b8d37bc256737 languageName: node linkType: hard @@ -9545,9 +9545,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": - version: 62.6.0 - resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.6.0&hash=1a3342" +"@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch": + version: 62.7.0 + resolution: "@metamask/transaction-controller@patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch::version=62.7.0&hash=1a3342" dependencies: "@ethereumjs/common": "npm:^4.4.0" "@ethereumjs/tx": "npm:^5.4.0" @@ -9579,7 +9579,7 @@ __metadata: peerDependencies: "@babel/runtime": ^7.0.0 "@metamask/eth-block-tracker": ">=9" - checksum: 10/b75b4a26082fb59a5a58bc8761471961d5ebab4529020005030ef28010c1dac6f6ba893c6777b66c85d8dd096625ff379515656166ffce619156fa52d8a8bc5b + checksum: 10/07f3ac5bcb5b47c1b056ba6ad444c5dfd87cfb1246d3aeab02e41635a03f0860c73fa6f4726bf9c561c98d198372ca48e5fb44ead0cfea4f8493952b38a0f863 languageName: node linkType: hard @@ -34163,7 +34163,7 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.0" "@metamask/bitcoin-wallet-snap": "npm:^1.8.0" - "@metamask/bridge-controller": "npm:^64.1.0" + "@metamask/bridge-controller": "npm:^64.2.0" "@metamask/bridge-status-controller": "npm:^64.0.1" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/build-utils": "npm:^3.0.0" @@ -34262,7 +34262,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/token-search-discovery-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" + "@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A62.7.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch" "@metamask/transaction-pay-controller": "npm:^10.5.0" "@metamask/tron-wallet-snap": "npm:^1.16.1" "@metamask/utils": "npm:^11.8.1" From 933d0499807cafa21936432a0ba32d359e2e6b38 Mon Sep 17 00:00:00 2001 From: Juanmi <95381763+juanmigdr@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:38:54 +0100 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20cleanup=20tokens=20list=20code?= =?UTF-8?q?=20=F0=9F=8E=84=20=20(#24074)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The tokens list is very badly organized and there is a lot of deprecated code, I have proceeded to reorganize the code and remove the deprecated parts of it as well as removed the dependency on the `isMultichainAccountsState2Enabled` FF. ## **Changelog** CHANGELOG entry: improve code quality on tokens list and remove deprecated code ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2159 ## **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. --- > [!NOTE] > Refactors token list architecture, drops the Token Filter sheet/routes, consolidates components/styles, and simplifies price-change logic to prefer multichain data with broad test updates. > > - **Tokens UI/Structure**: > - Reorganized `TokenList` and `TokenListItem` (removed `TokenListItemBip44`), updated imports/paths, and deleted legacy `styles.ts` and `CustomNetworkNativeImgMapping`. > - Localized styles in components (`TokenListItem`, `TokenListSkeleton`, `ScamWarningModal`, `StakeButton`). > - Removed `PortfolioBalance` and tests; Wallet now displays `AccountGroupBalance` only. > - Kept `ScamWarningModal` always mounted; visibility controlled by prop. > - **Navigation**: > - Removed `Routes.SHEET.TOKEN_FILTER` and its screen from `App.tsx`; eliminated `TokensBottomSheet` module. > - `TokenSortBottomSheet` now provides `createTokensBottomSheetNavDetails`; control bars updated to use it. > - **Sorting/Filtering**: > - Deleted `TokenFilterBottomSheet` and related tests; control bars continue to navigate to `NetworkManager` for network selection and `TokenSort` for sorting. > - **Data/Selectors**: > - Replaced `selectSortedTokenKeys` with `selectSortedAssetsBySelectedAccountGroup` in `Tokens`. > - Simplified `useTokenPricePercentageChange` to prefer multichain rates (`selectMultichainAssetsRates`) with EVM fallback; removed feature-flag/EVM selection branching. > - **Tests**: > - Updated paths/mocks and removed obsolete tests across token list, control bars, skeletons, and price-change hook to match the new structure. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fb40cc3df073bfedfeca16b274dc45c012fbff78. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/Nav/App/App.tsx | 7 +- .../UI/AssetOverview/Balance/index.test.tsx | 2 +- .../CardAssetItem/CardAssetItem.test.tsx | 3 - .../DeFiPositionsControlBar.test.tsx | 6 +- .../UI/Stake/components/StakeButton/index.tsx | 15 +- .../TokenList/PortfolioBalance/index.test.tsx | 210 -- .../TokenList/PortfolioBalance/index.tsx | 90 - .../{index.tsx => ScamWarningModal.tsx} | 40 +- .../{index.test.tsx => TokenList.test.tsx} | 4 +- .../TokenList/{index.tsx => TokenList.tsx} | 16 +- .../CustomNetworkNativeImgMapping.ts | 39 - .../ScamWarningIcon/ScamWarningIcon.test.tsx} | 14 +- .../ScamWarningIcon/ScamWarningIcon.tsx} | 10 +- ...mBip44.test.tsx => TokenListItem.test.tsx} | 27 +- ...kenListItemBip44.tsx => TokenListItem.tsx} | 39 +- .../TokenList/TokenListItem/index.test.tsx | 2372 ----------------- .../Tokens/TokenList/TokenListItem/index.tsx | 485 ---- .../TokenListSkeleton.test.tsx | 25 +- .../TokenListSkeleton.tsx | 25 +- .../TokenListControlBar.test.tsx | 17 +- .../UI/Tokens/TokenListControlBar/index.ts | 1 - .../TokenSortBottomSheet.test.tsx | 0 .../TokenSortBottomSheet.tsx | 24 +- .../TokenFilterBottomSheet.test.tsx | 260 -- .../TokenFilterBottomSheet.tsx | 130 - .../UI/Tokens/TokensBottomSheet/index.ts | 15 - .../useTokenPricePercentageChange.test.ts | 315 +-- .../hooks/useTokenPricePercentageChange.ts | 24 +- app/components/UI/Tokens/index.test.tsx | 2 +- app/components/UI/Tokens/index.tsx | 35 +- app/components/UI/Tokens/styles.ts | 125 - .../UI/Tokens/util/filterAssets.test.ts | 183 -- app/components/UI/Tokens/util/filterAssets.ts | 91 - .../BaseControlBar/BaseControlBar.test.tsx | 3 +- .../shared/BaseControlBar/BaseControlBar.tsx | 2 +- app/components/Views/Wallet/index.tsx | 7 +- app/constants/navigation/Routes.ts | 1 - app/selectors/tokenList.test.ts | 181 -- app/selectors/tokenList.ts | 74 - 39 files changed, 287 insertions(+), 4632 deletions(-) delete mode 100644 app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx delete mode 100644 app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx rename app/components/UI/Tokens/TokenList/ScamWarningModal/{index.tsx => ScamWarningModal.tsx} (73%) rename app/components/UI/Tokens/TokenList/{index.test.tsx => TokenList.test.tsx} (99%) rename app/components/UI/Tokens/TokenList/{index.tsx => TokenList.tsx} (90%) delete mode 100644 app/components/UI/Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping.ts rename app/components/UI/Tokens/TokenList/{ScamWarningIcon/index.test.tsx => TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx} (79%) rename app/components/UI/Tokens/TokenList/{ScamWarningIcon/index.tsx => TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx} (69%) rename app/components/UI/Tokens/TokenList/TokenListItem/{TokenListItemBip44.test.tsx => TokenListItem.test.tsx} (95%) rename app/components/UI/Tokens/TokenList/TokenListItem/{TokenListItemBip44.tsx => TokenListItem.tsx} (89%) delete mode 100644 app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx delete mode 100644 app/components/UI/Tokens/TokenList/TokenListItem/index.tsx rename app/components/UI/Tokens/TokenList/{ => TokenListSkeleton}/TokenListSkeleton.test.tsx (53%) rename app/components/UI/Tokens/TokenList/{ => TokenListSkeleton}/TokenListSkeleton.tsx (75%) delete mode 100644 app/components/UI/Tokens/TokenListControlBar/index.ts rename app/components/UI/Tokens/{TokensBottomSheet => TokenSortBottomSheet}/TokenSortBottomSheet.test.tsx (100%) rename app/components/UI/Tokens/{TokensBottomSheet => TokenSortBottomSheet}/TokenSortBottomSheet.tsx (87%) delete mode 100644 app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx delete mode 100644 app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx delete mode 100644 app/components/UI/Tokens/TokensBottomSheet/index.ts delete mode 100644 app/components/UI/Tokens/styles.ts delete mode 100644 app/components/UI/Tokens/util/filterAssets.test.ts delete mode 100644 app/components/UI/Tokens/util/filterAssets.ts delete mode 100644 app/selectors/tokenList.test.ts delete mode 100644 app/selectors/tokenList.ts diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 6d3af39e63f..5e401008299 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -38,9 +38,8 @@ import Toast, { } from '../../../component-library/components/Toast'; import AccountSelector from '../../../components/Views/AccountSelector'; import AddressSelector from '../../../components/Views/AddressSelector'; -import { TokenSortBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet'; +import { TokenSortBottomSheet } from '../../UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet'; import ProfilerManager from '../../../components/UI/ProfilerManager'; -import { TokenFilterBottomSheet } from '../../../components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet'; import NetworkManager from '../../../components/UI/NetworkManager'; import { AccountPermissionsScreens } from '../../../components/Views/AccountPermissions/AccountPermissions.types'; import AccountPermissionsConfirmRevokeAll from '../../../components/Views/AccountPermissions/AccountPermissionsConfirmRevokeAll'; @@ -485,10 +484,6 @@ const RootModalFlow = (props: RootModalFlowProps) => ( name={Routes.SHEET.TOKEN_SORT} component={TokenSortBottomSheet} /> - ({ diff --git a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx index 1aefb93e35f..6623c958de7 100644 --- a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx +++ b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx @@ -8,9 +8,6 @@ import { TokenI } from '../../../Tokens/types'; // Mock dependencies jest.mock('../../../../../util/networks'); jest.mock('../../../../../util/networks/customNetworks'); -jest.mock( - '../../../Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping', -); jest.mock('../../../../Base/RemoteImage', () => 'RemoteImage'); import { diff --git a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx index 3332b59e053..0d1a3fb4b37 100644 --- a/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiPositionsControlBar.test.tsx @@ -83,11 +83,7 @@ jest.mock('../../hooks/useNetworksByNamespace/useNetworksByNamespace', () => ({ }, })); -jest.mock('../Tokens/TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: () => [ - 'RootModalFlow', - { screen: 'TokenFilter' }, - ], +jest.mock('../Tokens/TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: () => [ 'RootModalFlow', { screen: 'TokensBottomSheet' }, diff --git a/app/components/UI/Stake/components/StakeButton/index.tsx b/app/components/UI/Stake/components/StakeButton/index.tsx index 4add77989e0..eb4b45e9736 100644 --- a/app/components/UI/Stake/components/StakeButton/index.tsx +++ b/app/components/UI/Stake/components/StakeButton/index.tsx @@ -1,7 +1,7 @@ import { toHex } from '@metamask/controller-utils'; import { useNavigation } from '@react-navigation/native'; import React, { useCallback } from 'react'; -import { Alert, TouchableOpacity } from 'react-native'; +import { Alert, StyleSheet, TouchableOpacity } from 'react-native'; import { useSelector } from 'react-redux'; import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; import { strings } from '../../../../../../locales/i18n'; @@ -19,7 +19,6 @@ import { selectNetworkConfigurationByChainId, } from '../../../../../selectors/networkController'; import { getDecimalChainId } from '../../../../../util/networks'; -import { useTheme } from '../../../../../util/theme'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { EARN_EXPERIENCES } from '../../../Earn/constants/experiences'; import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; @@ -28,7 +27,6 @@ import { selectPooledStakingEnabledFlag, selectStablecoinLendingEnabledFlag, } from '../../../Earn/selectors/featureFlags'; -import createStyles from '../../../Tokens/styles'; import { BrowserTab, TokenI } from '../../../Tokens/types'; import { EVENT_LOCATIONS } from '../../constants/events'; import useStakingChain from '../../hooks/useStakingChain'; @@ -46,14 +44,21 @@ import { useMusdConversion } from '../../../Earn/hooks/useMusdConversion'; import Logger from '../../../../../util/Logger'; import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; +const styles = StyleSheet.create({ + stakeButton: { + flexDirection: 'row', + }, + dot: { + marginLeft: 2, + marginRight: 2, + }, +}); interface StakeButtonProps { asset: TokenI; } // TODO: Rename to EarnCta to better describe this component's purpose. const StakeButtonContent = ({ asset }: StakeButtonProps) => { - const { colors } = useTheme(); - const styles = createStyles(colors); const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx deleted file mode 100644 index 92ef10f79df..00000000000 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React from 'react'; -import { fireEvent } from '@testing-library/react-native'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; -import { PortfolioBalance } from '.'; -import Engine from '../../../../../core/Engine'; - -const { PreferencesController } = Engine.context; - -// Mock the useMultichainBalances hook -const mockSelectedAccountMultichainBalance = { - displayBalance: '$123.45', - totalFiatBalance: '123.45', - shouldShowAggregatedPercentage: true, - tokenFiatBalancesCrossChains: [], -}; - -jest.mock('../../../../hooks/useMultichainBalances', () => ({ - useSelectedAccountMultichainBalances: () => ({ - selectedAccountMultichainBalance: mockSelectedAccountMultichainBalance, - }), -})); - -jest.mock('../../../../../core/Engine', () => ({ - getTotalEvmFiatAccountBalance: jest.fn(), - context: { - TokensController: { - ignoreTokens: jest.fn(() => Promise.resolve()), - }, - PreferencesController: { - setPrivacyMode: jest.fn(), - }, - NetworkController: { - getNetworkClientById: () => ({ - configuration: { - chainId: '0x1', - rpcUrl: 'https://mainnet.infura.io/v3', - ticker: 'ETH', - type: 'custom', - }, - }), - state: { - selectedNetworkClientId: 'mainnet', - }, - }, - }, -})); - -const initialState = { - engine: { - backgroundState: { - ...backgroundState, - NetworkController: { - selectedNetworkClientId: 'mainnet', - networkConfigurationsByChainId: { - '0x1': { - blockExplorerUrls: [], - chainId: '0x1', - defaultRpcEndpointIndex: 1, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - networkClientId: 'mainnet', - type: 'infura', - url: 'https://mainnet.infura.io/v3/{infuraProjectId}', - }, - { - name: 'public', - networkClientId: 'ea57f659-c004-4902-bfca-0c9688a43872', - type: 'custom', - url: 'https://mainnet-rpc.publicnode.com', - }, - ], - }, - }, - }, - TokensController: { - tokens: [ - { - name: 'Ethereum', - symbol: 'ETH', - address: '0x0', - decimals: 18, - isETH: true, - - balanceFiat: '< $0.01', - iconUrl: '', - }, - { - name: 'Bat', - symbol: 'BAT', - address: '0x01', - decimals: 18, - balanceFiat: '$0', - iconUrl: '', - }, - { - name: 'Link', - symbol: 'LINK', - address: '0x02', - decimals: 18, - balanceFiat: '$0', - iconUrl: '', - }, - ], - }, - TokenRatesController: { - marketData: { - '0x1': { - '0x0': { price: 0.005 }, - '0x01': { price: 0.005 }, - '0x02': { price: 0.005 }, - }, - }, - }, - CurrencyRateController: { - currentCurrency: 'USD', - currencyRates: { - ETH: { - conversionRate: 1, - }, - }, - }, - TokenBalancesController: { - tokenBalances: {}, - }, - MultichainNetworkController: { - isEvmSelected: true, - }, - }, - }, - settings: { - primaryCurrency: 'usd', - hideZeroBalanceTokens: true, - }, - security: { - dataCollectionForMarketing: true, - }, -}; - -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const renderPortfolioBalance = (state: any = {}) => - renderWithProvider(, { state }); - -describe('PortfolioBalance', () => { - it('fiat balance must be defined', () => { - const { getByTestId } = renderPortfolioBalance(initialState); - expect( - getByTestId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT), - ).toBeDefined(); - }); - - it('renders sensitive text when privacy mode is off', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: false, - }, - }, - }, - }); - const sensitiveText = getByTestId( - WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT, - ); - expect(sensitiveText.props.isHidden).toBeFalsy(); - }); - - it('hides sensitive text when privacy mode is on', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: true, - }, - }, - }, - }); - const sensitiveText = getByTestId( - WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT, - ); - expect(sensitiveText.props.children).toEqual('••••••••••••'); - }); - - it('toggles privacy mode when balance container is pressed', () => { - const { getByTestId } = renderPortfolioBalance({ - ...initialState, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - PreferencesController: { - privacyMode: false, - }, - }, - }, - }); - - const balanceContainer = getByTestId('balance-container'); - fireEvent.press(balanceContainer); - - expect(PreferencesController.setPrivacyMode).toHaveBeenCalledWith(true); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx deleted file mode 100644 index 0794f0abf6f..00000000000 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useCallback } from 'react'; -import { View, TouchableOpacity } from 'react-native'; -import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../../util/theme'; -import Engine from '../../../../../core/Engine'; -import { selectPrivacyMode } from '../../../../../selectors/preferencesController'; -import createStyles from '../../styles'; -import { TextVariant } from '../../../../../component-library/components/Texts/Text'; -import SensitiveText, { - SensitiveTextLength, -} from '../../../../../component-library/components/Texts/SensitiveText'; -import { WalletViewSelectorsIDs } from '../../../../../../e2e/selectors/wallet/WalletView.selectors'; -import AggregatedPercentageCrossChains from '../../../../../component-library/components-temp/Price/AggregatedPercentage/AggregatedPercentageCrossChains'; -import { useSelectedAccountMultichainBalances } from '../../../../hooks/useMultichainBalances'; -import { Skeleton } from '../../../../../component-library/components/Skeleton'; -import NonEvmAggregatedPercentage from '../../../../../component-library/components-temp/Price/AggregatedPercentage/NonEvmAggregatedPercentage'; -import { selectIsEvmNetworkSelected } from '../../../../../selectors/multichainNetworkController'; - -export const PortfolioBalance = React.memo(() => { - const { PreferencesController } = Engine.context; - const { colors } = useTheme(); - const styles = createStyles(colors); - const privacyMode = useSelector(selectPrivacyMode); - - const { selectedAccountMultichainBalance } = - useSelectedAccountMultichainBalances(); - const isEvmSelected = useSelector(selectIsEvmNetworkSelected); - - const renderAggregatedPercentage = () => { - if ( - !selectedAccountMultichainBalance || - !selectedAccountMultichainBalance?.shouldShowAggregatedPercentage || - selectedAccountMultichainBalance?.totalFiatBalance === undefined - ) { - return null; - } - - if (!isEvmSelected) { - return ; - } - - return ( - - ); - }; - - const toggleIsBalanceAndAssetsHidden = useCallback( - (value: boolean) => { - PreferencesController.setPrivacyMode(value); - }, - [PreferencesController], - ); - - return ( - - - {selectedAccountMultichainBalance?.displayBalance ? ( - toggleIsBalanceAndAssetsHidden(!privacyMode)} - testID="balance-container" - > - - - {selectedAccountMultichainBalance?.displayBalance} - - - - {renderAggregatedPercentage()} - - ) : ( - - - - - )} - - - ); -}); diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx similarity index 73% rename from app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx rename to app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx index ba4613ced32..10b8044599d 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/index.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.tsx @@ -1,10 +1,9 @@ import React from 'react'; import Modal from 'react-native-modal'; import { useTheme } from '../../../../../util/theme'; -import createStyles from '../../styles'; import Box from '../../../Ramp/Aggregator/components/Box'; -import { View } from 'react-native'; -import SheetHeader from '../../../../../../app/component-library/components/Sheet/SheetHeader'; +import { StyleSheet, View } from 'react-native'; +import SheetHeader from '../../../../../component-library/components/Sheet/SheetHeader'; import { strings } from '../../../../../../locales/i18n'; import Text from '../../../../../component-library/components/Texts/Text'; import Button, { @@ -18,7 +17,39 @@ import { import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../../constants/navigation/Routes'; +import { Colors } from '../../../../../util/theme/models'; +const createStyles = (colors: Colors) => + StyleSheet.create({ + bottomModal: { + justifyContent: 'flex-end', + margin: 0, + }, + box: { + backgroundColor: colors.background.default, + paddingHorizontal: 8, + paddingBottom: 20, + borderWidth: 0, + padding: 0, + }, + boxContent: { + backgroundColor: colors.background.default, + paddingBottom: 21, + paddingTop: 0, + borderWidth: 0, + }, + editNetworkButton: { + width: '100%', + }, + notch: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: colors.border.muted, + alignSelf: 'center', + marginTop: 4, + }, + }); interface ScamWarningModalProps { showScamWarningModal: boolean; setShowScamWarningModal: (arg: boolean) => void; @@ -30,12 +61,11 @@ export const ScamWarningModal = ({ }: ScamWarningModalProps) => { const navigation = useNavigation(); const { colors } = useTheme(); + const styles = createStyles(colors); const ticker = useSelector(selectEvmTicker); const { rpcUrl } = useSelector(selectProviderConfig); - const styles = createStyles(colors); - const goToNetworkEdit = () => { navigation.navigate(Routes.ADD_NETWORK, { network: rpcUrl, diff --git a/app/components/UI/Tokens/TokenList/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx similarity index 99% rename from app/components/UI/Tokens/TokenList/index.test.tsx rename to app/components/UI/Tokens/TokenList/TokenList.test.tsx index 08ca55037fa..78420cb4376 100644 --- a/app/components/UI/Tokens/TokenList/index.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; -import { TokenList } from './index'; +import { TokenList } from './TokenList'; import { useNavigation } from '@react-navigation/native'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useMetrics } from '../../../hooks/useMetrics'; @@ -51,7 +51,7 @@ jest.mock('../../../../selectors/featureFlagController/homepage', () => ({ })); // Mock child components -jest.mock('./TokenListItem', () => ({ +jest.mock('./TokenListItem/TokenListItem', () => ({ TokenListItem: ({ assetKey }: { assetKey: { address: string } }) => { const React = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); diff --git a/app/components/UI/Tokens/TokenList/index.tsx b/app/components/UI/Tokens/TokenList/TokenList.tsx similarity index 90% rename from app/components/UI/Tokens/TokenList/index.tsx rename to app/components/UI/Tokens/TokenList/TokenList.tsx index 9489f823c33..cbd3e709765 100644 --- a/app/components/UI/Tokens/TokenList/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.tsx @@ -10,11 +10,10 @@ import { import { TokenI } from '../types'; import { strings } from '../../../../../locales/i18n'; -import { TokenListItem, TokenListItemBip44 } from './TokenListItem'; +import { TokenListItem } from './TokenListItem/TokenListItem'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import { useNavigation } from '@react-navigation/native'; import Routes from '../../../../constants/navigation/Routes'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; import { selectHomepageRedesignV1Enabled } from '../../../../selectors/featureFlagController/homepage'; import { Box, @@ -61,14 +60,6 @@ const TokenListComponent = ({ selectHomepageRedesignV1Enabled, ); - // BIP44 MAINTENANCE: Once stable, only use TokenListItemBip44 - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - const TokenListItemComponent = isMultichainAccountsState2Enabled - ? TokenListItemBip44 - : TokenListItem; - const listRef = useRef>(null); const navigation = useNavigation(); @@ -101,7 +92,7 @@ const TokenListComponent = ({ const renderTokenListItem = useCallback( ({ item }: { item: FlashListAssetKey }) => ( - {displayTokenKeys.map((item, index) => ( - = { - [NETWORK_CHAIN_ID.FLARE_MAINNET]: FlareMainnetImg, - [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: SongbirdImg, - [NETWORK_CHAIN_ID.APECHAIN_TESTNET]: ApeNetworkImg, - [NETWORK_CHAIN_ID.APECHAIN_MAINNET]: ApeNetworkImg, - [NETWORK_CHAIN_ID.GRAVITY_ALPHA_MAINNET]: GravityImg, - [NETWORK_CHAIN_ID.KAIA_MAINNET]: KaiaImg, - [NETWORK_CHAIN_ID.KAIA_KAIROS_TESTNET]: KaiaImg, - [NETWORK_CHAIN_ID.SONEIUM_MAINNET]: ethImg, - [NETWORK_CHAIN_ID.SONEIUM_MINATO_TESTNET]: ethImg, - [NETWORK_CHAIN_ID.XRPLEVM_TESTNET]: XrpLevmImg, - [NETWORK_CHAIN_ID.SOPHON]: SophonImg, - [NETWORK_CHAIN_ID.SOPHON_TESTNET]: SophonTestnetImg, - [NETWORK_CHAIN_ID.MEGAETH_MAINNET]: ethImg, - [NETWORK_CHAIN_ID.MEGAETH_TESTNET]: MegaethTestnetImg, - [NETWORK_CHAIN_ID.LUKSO]: LuksoImg, - [NETWORK_CHAIN_ID.INJECTIVE]: InjectiveImg, - [NETWORK_CHAIN_ID.PLASMA]: PlasmaImg, - [NETWORK_CHAIN_ID.HYPE]: HypeImg, -}; diff --git a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx similarity index 79% rename from app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx index 4760197993a..cacdf2fa13f 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.test.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; -import { TokenI } from '../../types'; -import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { ScamWarningIcon } from '.'; -import ButtonIcon from '../../../../../component-library/components/Buttons/ButtonIcon'; -import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import useIsOriginalNativeTokenSymbol from '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; +import { TokenI } from '../../../types'; +import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import { ScamWarningIcon } from './ScamWarningIcon'; +import ButtonIcon from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import { IconName } from '../../../../../../component-library/components/Icons/Icon'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -13,7 +13,7 @@ jest.mock('react-redux', () => ({ })); jest.mock( - '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol', + '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol', () => ({ __esModule: true, default: jest.fn(), diff --git a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx similarity index 69% rename from app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx index f709d8a3185..642d3c6ab81 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningIcon/index.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/ScamWarningIcon/ScamWarningIcon.tsx @@ -1,15 +1,15 @@ import React from 'react'; -import { TokenI } from '../../types'; -import useIsOriginalNativeTokenSymbol from '../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; +import { TokenI } from '../../../types'; +import useIsOriginalNativeTokenSymbol from '../../../../../hooks/useIsOriginalNativeTokenSymbol/useIsOriginalNativeTokenSymbol'; import { useSelector } from 'react-redux'; -import { selectProviderConfig } from '../../../../../selectors/networkController'; +import { selectProviderConfig } from '../../../../../../selectors/networkController'; import ButtonIcon, { ButtonIconSizes, -} from '../../../../../../app/component-library/components/Buttons/ButtonIcon'; +} from '../../../../../../component-library/components/Buttons/ButtonIcon'; import { IconColor, IconName, -} from '../../../../../component-library/components/Icons/Icon'; +} from '../../../../../../component-library/components/Icons/Icon'; interface ScamWarningIconProps { asset: TokenI & { chainId: string }; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx similarity index 95% rename from app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index cc139cdec24..2b464ee4dbb 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItemBip44.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1,11 +1,8 @@ import { BtcAccountType } from '@metamask/keyring-api'; import React from 'react'; import { useSelector } from 'react-redux'; -import { - ACCOUNT_TYPE_LABEL_TEST_ID, - TokenListItemBip44, -} from './TokenListItemBip44'; -import { FlashListAssetKey } from '..'; +import { ACCOUNT_TYPE_LABEL_TEST_ID, TokenListItem } from './TokenListItem'; +import { FlashListAssetKey } from '../TokenList'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; import { isTestNet } from '../../../../../util/networks'; import { formatWithThreshold } from '../../../../../util/assets'; @@ -156,12 +153,6 @@ jest.mock('../../../../../constants/popular-networks', () => ({ POPULAR_NETWORK_CHAIN_IDS: new Set(['0x1', '0xe708']), })); -jest.mock('./CustomNetworkNativeImgMapping', () => ({ - CustomNetworkNativeImgMapping: { - '0x89': { uri: 'polygon-native.png' }, - }, -})); - // Mock useSelector to return controlled data jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -211,7 +202,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { const selectorString = selector.toString(); - // TokenListItemBip44 selectors + // TokenListItem selectors if (selectorString.includes('selectAsset')) { return asset; } @@ -265,7 +256,7 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { }; const { getByText } = renderWithProvider( - { }; const { getByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - { }; const { queryByTestId } = renderWithProvider( - + StyleSheet.create({ + balances: { + flex: 1, + justifyContent: 'center', + marginLeft: 20, + }, + balanceFiat: { + color: colors.text.alternative, + ...fontStyles.normal, + textTransform: 'uppercase', + }, + badge: { + marginTop: 8, + }, + assetName: { + flexDirection: 'row', + gap: 8, + }, + percentageChange: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + }, + }); + interface TokenListItemProps { assetKey: FlashListAssetKey; showRemoveMenu: (arg: TokenI) => void; @@ -53,7 +80,7 @@ interface TokenListItemProps { isFullView?: boolean; } -export const TokenListItemBip44 = React.memo( +export const TokenListItem = React.memo( ({ assetKey, showRemoveMenu, @@ -245,4 +272,4 @@ export const TokenListItemBip44 = React.memo( }, ); -TokenListItemBip44.displayName = 'TokenListItemBip44'; +TokenListItem.displayName = 'TokenListItem'; diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx deleted file mode 100644 index 6b28b021fea..00000000000 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.test.tsx +++ /dev/null @@ -1,2372 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { Provider, useSelector } from 'react-redux'; -import { NavigationContainer } from '@react-navigation/native'; -import { configureStore } from '@reduxjs/toolkit'; -import { TextColor } from '../../../../../component-library/components/Texts/Text'; -import { TOKEN_RATE_UNDEFINED } from '../../constants'; -import { TokenListItem } from './index'; -import { FlashListAssetKey } from '..'; - -// Mock dependencies -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: jest.fn(), - }), -})); - -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ colors: {} }), -})); - -jest.mock('../../../../hooks/useMetrics', () => ({ - useMetrics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn(() => ({ - build: jest.fn(), - addProperties: jest.fn(() => ({ build: jest.fn() })), - })), - }), -})); - -jest.mock('../../hooks/useTokenPricePercentageChange', () => ({ - useTokenPricePercentageChange: jest.fn(), -})); - -jest.mock('../../../Earn/hooks/useEarnTokens', () => ({ - __esModule: true, - default: () => ({ getEarnToken: jest.fn() }), -})); - -jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ - useMusdConversion: () => ({ - initiateConversion: jest.fn(), - error: null, - }), -})); - -jest.mock('../../../Earn/hooks/useMusdConversionTokens', () => ({ - useMusdConversionTokens: jest.fn(() => ({ - isConversionToken: jest.fn().mockReturnValue(false), - tokenFilter: jest.fn(), - tokens: [], - })), -})); - -jest.mock('../../../../../selectors/earnController/earn', () => ({ - earnSelectors: { - selectPrimaryEarnExperienceTypeForAsset: jest.fn(() => 'pooled-staking'), - }, -})); - -jest.mock('../../../Stake/hooks/useStakingChain', () => ({ - useStakingChainByChainId: () => ({ isStakingSupportedChain: false }), -})); - -jest.mock('../../../Earn/selectors/featureFlags', () => ({ - selectPooledStakingEnabledFlag: () => true, // Enable to show Earn button - selectStablecoinLendingEnabledFlag: () => false, - selectIsMusdConversionFlowEnabledFlag: () => false, - selectMusdConversionPaymentTokensAllowlist: () => ({}), -})); - -jest.mock('../../util/deriveBalanceFromAssetMarketDetails', () => ({ - deriveBalanceFromAssetMarketDetails: jest.fn(() => ({ - balanceFiat: '$100.00', - balanceValueFormatted: '1.23 ETH', - })), -})); - -jest.mock('../../../../../util/assets', () => ({ - formatWithThreshold: jest.fn((value) => `${value} TEST`), -})); - -jest.mock('../../../../../util/networks', () => { - const actual = jest.requireActual('../../../../../util/networks'); - - return { - ...actual, - getDefaultNetworkByChainId: jest.fn(), - getTestNetImageByChainId: jest.fn(() => 'testnet.png'), - isTestNet: jest.fn(), - }; -}); - -jest.mock('../../../../../util/networks/customNetworks', () => { - const actual = jest.requireActual( - '../../../../../util/networks/customNetworks', - ); - - return { - ...actual, - CustomNetworkImgMapping: {}, - PopularList: [], - UnpopularNetworkList: [], - getNonEvmNetworkImageSourceByChainId: jest.fn(), - }; -}); - -jest.mock('../../../../../constants/network', () => ({ - NETWORKS_CHAIN_ID: { - MAINNET: '0x1', - OPTIMISM: '0xa', - BSC: '0x38', - POLYGON: '0x89', - FANTOM: '0xfa', - BASE: '0x2105', - ARBITRUM: '0xa4b1', - AVAXCCHAIN: '0xa86a', - CELO: '0xa4ec', - HARMONY: '0x63564c40', - SEPOLIA: '0xaa36a7', - LINEA_GOERLI: '0xe704', - LINEA_SEPOLIA: '0xe705', - GOERLI: '0x5', - LINEA_MAINNET: '0xe708', - ZKSYNC_ERA: '0x144', - LOCALHOST: '0x539', - ARBITRUM_GOERLI: '0x66eed', - OPTIMISM_GOERLI: '0x1a4', - MUMBAI: '0x13881', - OPBNB: '0xcc', - SCROLL: '0x82750', - BERACHAIN: '0x138d6', - METACHAIN_ONE: '0x1b6a6', - MEGAETH_TESTNET: '0x18c6', - SEI: '0x531', - MONAD_TESTNET: '0x279f', - }, - NETWORK_CHAIN_ID: { - FLARE_MAINNET: '0x13', - SONGBIRD_TESTNET: '0x14', - APECHAIN_TESTNET: '0x15', - APECHAIN_MAINNET: '0x16', - }, -})); - -jest.mock('../../../../../constants/popular-networks', () => ({ - POPULAR_NETWORK_CHAIN_IDS: new Set(['0x1', '0xe708']), -})); - -jest.mock('./CustomNetworkNativeImgMapping', () => ({ - CustomNetworkNativeImgMapping: { - '0x89': 'polygon-native.png', - '0xa86a': 'avalanche-native.png', - }, -})); - -// Mock all selectors -const mockStore = configureStore({ - reducer: { - root: (state = {}) => state, - }, - preloadedState: { - root: {}, - }, -}); - -const MockProvider = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -// Mock useSelector to return controlled data -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -describe('TokenListItem - Core Logic', () => { - describe('percentage availability check', () => { - const hasPercentageChange = ( - _chainId: string, - showPercentageChange: boolean, - pricePercentChange1d: number | null | undefined, - isTestNet: boolean = false, - ): boolean => - !isTestNet && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - describe('when on mainnet', () => { - it('returns true for valid finite percentage', () => { - // Arrange - const validPercentage = 5.67; - - // Act - const result = hasPercentageChange('0x1', true, validPercentage, false); - - // Assert - expect(result).toBe(true); - }); - - it('returns false for null percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, null, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for undefined percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, undefined, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false when showPercentageChange is disabled', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', false, 5.67, false); - - // Assert - expect(result).toBe(false); - }); - }); - - describe('when on testnet', () => { - it('returns false even with valid percentage', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, 5.67, true); - - // Assert - expect(result).toBe(false); - }); - }); - - describe('critical edge cases - prevents crash', () => { - it('returns false for Infinity to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, Infinity, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for negative Infinity to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, -Infinity, false); - - // Assert - expect(result).toBe(false); - }); - - it('returns false for NaN to prevent toFixed crash', () => { - // Arrange & Act - const result = hasPercentageChange('0x1', true, NaN, false); - - // Assert - expect(result).toBe(false); - }); - }); - }); - - describe('percentage color logic', () => { - const getPercentageColor = ( - pricePercentChange1d: number | null, - hasPercentageChange: boolean, - ): TextColor => { - if (!hasPercentageChange) return TextColor.Alternative; - if (pricePercentChange1d === 0) return TextColor.Alternative; - if (pricePercentChange1d && pricePercentChange1d > 0) - return TextColor.Success; - return TextColor.Error; - }; - - describe('when percentage change is available', () => { - it('returns success color for positive percentage change', () => { - // Arrange - const positivePercentage = 5.67; - - // Act - const result = getPercentageColor(positivePercentage, true); - - // Assert - expect(result).toBe(TextColor.Success); - }); - - it('returns error color for negative percentage change', () => { - // Arrange - const negativePercentage = -3.25; - - // Act - const result = getPercentageColor(negativePercentage, true); - - // Assert - expect(result).toBe(TextColor.Error); - }); - - it('returns alternative color for zero percentage change', () => { - // Arrange - const zeroPercentage = 0; - - // Act - const result = getPercentageColor(zeroPercentage, true); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - - it('returns alternative color for very small positive change', () => { - // Arrange - const smallPositive = 0.01; - - // Act - const result = getPercentageColor(smallPositive, true); - - // Assert - expect(result).toBe(TextColor.Success); - }); - - it('returns error color for very small negative change', () => { - // Arrange - const smallNegative = -0.01; - - // Act - const result = getPercentageColor(smallNegative, true); - - // Assert - expect(result).toBe(TextColor.Error); - }); - }); - - describe('when percentage change is not available', () => { - it('returns alternative color when percentage not available', () => { - // Arrange & Act - const result = getPercentageColor(null, false); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - - it('returns alternative color even with valid percentage when disabled', () => { - // Arrange & Act - const result = getPercentageColor(5.67, false); - - // Assert - expect(result).toBe(TextColor.Alternative); - }); - }); - }); - - describe('percentage text formatting', () => { - const formatPercentageText = ( - value: number | null | undefined, - hasChange: boolean, - ): string | undefined => { - if (!hasChange || value === null || value === undefined) return undefined; - if (!Number.isFinite(value)) return undefined; // Critical safety check - return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`; - }; - - describe('valid formatting cases', () => { - it('formats positive percentages with plus sign', () => { - // Arrange - const positiveValue = 12.345; - - // Act - const result = formatPercentageText(positiveValue, true); - - // Assert - expect(result).toBe('+12.35%'); - }); - - it('formats negative percentages correctly', () => { - // Arrange - const negativeValue = -8.91; - - // Act - const result = formatPercentageText(negativeValue, true); - - // Assert - expect(result).toBe('-8.91%'); - }); - - it('formats zero percentage with plus sign', () => { - // Arrange - const zeroValue = 0; - - // Act - const result = formatPercentageText(zeroValue, true); - - // Assert - expect(result).toBe('+0.00%'); - }); - - it('formats large positive percentages correctly', () => { - // Arrange - const largePositive = 999.999; - - // Act - const result = formatPercentageText(largePositive, true); - - // Assert - expect(result).toBe('+1000.00%'); - }); - - it('formats large negative percentages correctly', () => { - // Arrange - const largeNegative = -99.99; - - // Act - const result = formatPercentageText(largeNegative, true); - - // Assert - expect(result).toBe('-99.99%'); - }); - }); - - describe('edge cases that return undefined', () => { - it('returns undefined when no percentage available', () => { - // Arrange & Act - const result = formatPercentageText(null, false); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for null value', () => { - // Arrange & Act - const result = formatPercentageText(null, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for undefined value', () => { - // Arrange & Act - const result = formatPercentageText(undefined, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined when hasChange is false', () => { - // Arrange & Act - const result = formatPercentageText(5.67, false); - - // Assert - expect(result).toBeUndefined(); - }); - }); - - describe('critical safety checks - prevents application crashes', () => { - it('returns undefined for Infinity instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(Infinity, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for negative Infinity instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(-Infinity, true); - - // Assert - expect(result).toBeUndefined(); - }); - - it('returns undefined for NaN instead of crashing', () => { - // Arrange & Act - const result = formatPercentageText(NaN, true); - - // Assert - expect(result).toBeUndefined(); - }); - - // Test the test: Verify that without safety check, toFixed produces invalid results - it('demonstrates why safety check is needed', () => { - // Arrange - const unsafeFormat = (value: number) => value.toFixed(2); - - // Act & Assert - toFixed doesn't crash but produces invalid percentage strings - expect(unsafeFormat(Infinity)).toBe('Infinity'); - expect(unsafeFormat(NaN)).toBe('NaN'); - expect(unsafeFormat(-Infinity)).toBe('-Infinity'); - - // These would result in invalid percentage text like "Infinity%" or "NaN%" - const unsafePercentage = (value: number) => - `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`; - expect(unsafePercentage(Infinity)).toBe('+Infinity%'); - expect(unsafePercentage(NaN)).toBe('NaN%'); // NaN >= 0 is false, so no + prefix - expect(unsafePercentage(-Infinity)).toBe('-Infinity%'); - }); - }); - }); - - describe('percentage display priority logic', () => { - const getDisplayPriority = ( - hasPercentageChange: boolean, - hasBalanceError: boolean, - isRateUndefined: boolean, - ): 'percentage' | 'error' | 'rate_error' => { - if (hasBalanceError) return 'error'; - if (isRateUndefined) return 'rate_error'; - if (hasPercentageChange) return 'percentage'; - return 'percentage'; // fallback - }; - - it('prioritizes balance error over percentage', () => { - // Arrange & Act - const result = getDisplayPriority(true, true, false); - - // Assert - expect(result).toBe('error'); - }); - - it('prioritizes rate error over percentage when no balance error', () => { - // Arrange & Act - const result = getDisplayPriority(true, false, true); - - // Assert - expect(result).toBe('rate_error'); - }); - - it('shows percentage when no errors', () => { - // Arrange & Act - const result = getDisplayPriority(true, false, false); - - // Assert - expect(result).toBe('percentage'); - }); - - it('shows percentage fallback when no percentage change available', () => { - // Arrange & Act - const result = getDisplayPriority(false, false, false); - - // Assert - expect(result).toBe('percentage'); - }); - }); - - describe('parameterized edge case testing', () => { - describe.each([ - { - value: 0.001, - expected: '+0.00%', - description: 'very small positive rounds to zero', - }, - { - value: -0.001, - expected: '-0.00%', - description: 'very small negative rounds to zero', - }, - { value: 0.005, expected: '+0.01%', description: 'rounding up at 0.5' }, - { - value: -0.005, - expected: '-0.01%', - description: 'rounding down at -0.5', - }, - { - value: 100, - expected: '+100.00%', - description: 'exact hundred percent', - }, - { - value: -100, - expected: '-100.00%', - description: 'exact negative hundred percent', - }, - ])( - 'percentage formatting edge cases', - ({ value, expected, description }) => { - it(`correctly formats ${description}`, () => { - // Arrange - const formatPercentageText = (val: number) => - `${val >= 0 ? '+' : ''}${val.toFixed(2)}%`; - - // Act - const result = formatPercentageText(value); - - // Assert - expect(result).toBe(expected); - }); - }, - ); - }); -}); - -describe('TokenListItem - Utility Logic Tests', () => { - describe('Balance Display Logic', () => { - const testBalanceDisplayLogic = ( - balanceFiat: string | undefined, - balanceValueFormatted: string | undefined, - hasBalanceError: boolean, - isTestNet: boolean, - showFiatOnTestnets: boolean, - ) => { - let mainBalance; - let secondaryBalance; - const shouldNotShowBalanceOnTestnets = isTestNet && !showFiatOnTestnets; - - // Mirror the logic from the component - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - mainBalance = undefined; - } else { - mainBalance = balanceFiat ?? 'Unable to find conversion rate'; - } - - if (hasBalanceError) { - mainBalance = 'ETH'; // Mock symbol - secondaryBalance = 'Unable to load'; - } - - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = balanceValueFormatted; - secondaryBalance = 'Unable to find conversion rate'; - } - - return { mainBalance, secondaryBalance }; - }; - - it('displays fiat balance when available on mainnet', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('$1000.00'); - }); - - it('hides balance on testnet when showFiatOnTestnets is false', () => { - const result = testBalanceDisplayLogic( - undefined, - '2.5 ETH', - false, - true, - false, - ); - expect(result.mainBalance).toBeUndefined(); - }); - - it('shows balance on testnet when showFiatOnTestnets is true', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - false, - true, - true, - ); - expect(result.mainBalance).toBe('$1000.00'); - }); - - it('shows error message when balance has error', () => { - const result = testBalanceDisplayLogic( - '$1000.00', - '2.5 ETH', - true, - false, - false, - ); - expect(result.mainBalance).toBe('ETH'); - expect(result.secondaryBalance).toBe('Unable to load'); - }); - - it('shows token amount when rate is undefined', () => { - const result = testBalanceDisplayLogic( - TOKEN_RATE_UNDEFINED, - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('2.5 ETH'); - expect(result.secondaryBalance).toBe('Unable to find conversion rate'); - }); - - it('shows fallback message when no fiat available', () => { - const result = testBalanceDisplayLogic( - undefined, - '2.5 ETH', - false, - false, - false, - ); - expect(result.mainBalance).toBe('Unable to find conversion rate'); - }); - }); - - describe('Network Badge Logic', () => { - const testNetworkBadgeLogic = (chainId: string) => { - // Simplified version of the networkBadgeSource logic - const testNetworkMapping: Record = { - '0x1': 'mainnet-image.png', - '0x5': 'goerli-image.png', - '0x89': 'polygon-image.png', - }; - - if (chainId.startsWith('0x5') || chainId.startsWith('0x4')) { - return 'testnet-image.png'; - } - - return testNetworkMapping[chainId] || 'default-image.png'; - }; - - it('returns mainnet image for Ethereum mainnet', () => { - expect(testNetworkBadgeLogic('0x1')).toBe('mainnet-image.png'); - }); - - it('returns testnet image for Goerli', () => { - expect(testNetworkBadgeLogic('0x5')).toBe('testnet-image.png'); - }); - - it('returns polygon image for Polygon', () => { - expect(testNetworkBadgeLogic('0x89')).toBe('polygon-image.png'); - }); - - it('returns default image for unknown network', () => { - expect(testNetworkBadgeLogic('0x999')).toBe('default-image.png'); - }); - }); - - describe('Asset Type Logic', () => { - const testAssetTypeLogic = (asset: { - isNative: boolean; - isETH: boolean; - }) => { - if (asset.isNative) { - return 'native'; - } - if (asset.isETH) { - return 'eth'; - } - return 'token'; - }; - - it('identifies native assets correctly', () => { - const nativeAsset = { isNative: true, isETH: false }; - expect(testAssetTypeLogic(nativeAsset)).toBe('native'); - }); - - it('identifies ETH assets correctly', () => { - const ethAsset = { isNative: false, isETH: true }; - expect(testAssetTypeLogic(ethAsset)).toBe('eth'); - }); - - it('identifies regular tokens correctly', () => { - const tokenAsset = { isNative: false, isETH: false }; - expect(testAssetTypeLogic(tokenAsset)).toBe('token'); - }); - - it('prioritizes native over ETH when both are true', () => { - const nativeEthAsset = { isNative: true, isETH: true }; - expect(testAssetTypeLogic(nativeEthAsset)).toBe('native'); - }); - }); - - describe('Long Press Logic', () => { - const testLongPressLogic = (asset: { isETH: boolean; isNative: boolean }) => - // Mirror the onLongPress logic from component - asset.isETH || asset.isNative ? null : 'showRemoveMenu'; - it('disables long press for ETH', () => { - const ethAsset = { isETH: true, isNative: false }; - expect(testLongPressLogic(ethAsset)).toBeNull(); - }); - - it('disables long press for native assets', () => { - const nativeAsset = { isETH: false, isNative: true }; - expect(testLongPressLogic(nativeAsset)).toBeNull(); - }); - - it('enables long press for regular tokens', () => { - const tokenAsset = { isETH: false, isNative: false }; - expect(testLongPressLogic(tokenAsset)).toBe('showRemoveMenu'); - }); - - it('disables long press when both ETH and native are true', () => { - const ethNativeAsset = { isETH: true, isNative: true }; - expect(testLongPressLogic(ethNativeAsset)).toBeNull(); - }); - }); -}); - -describe('TokenListItem - Advanced Component Logic', () => { - describe('Balance Calculation and Formatting', () => { - const testBalanceDerivation = ( - asset: { - address: string; - symbol: string; - balance?: string; - balanceFiat?: string; - } | null, - exchangeRates: Record, - tokenBalances: Record, - conversionRate: number, - _currentCurrency: string, - isEvmNetworkSelected: boolean, - ) => { - if (!isEvmNetworkSelected || !asset) { - return { - balanceFiat: asset?.balanceFiat - ? `$${asset.balanceFiat}` - : 'Loading...', - balanceValueFormatted: asset?.balance - ? `${asset.balance} ${asset.symbol}` - : 'Loading...', - }; - } - - // Simplified balance derivation logic - const rate = exchangeRates[asset.address]?.price || 0; - const balance = tokenBalances[asset.address] || '0'; - const balanceNum = parseFloat(balance); - const fiatValue = balanceNum * rate * conversionRate; - - return { - balanceFiat: fiatValue > 0 ? `$${fiatValue.toFixed(2)}` : '$0.00', - balanceValueFormatted: `${balanceNum} ${asset.symbol}`, - }; - }; - - it('calculates fiat balance correctly for EVM assets', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '100' }; - const exchangeRates = { '0x123': { price: 2.5 } }; - const tokenBalances = { '0x123': '100' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$250.00'); - expect(result.balanceValueFormatted).toBe('100 TEST'); - }); - - it('handles non-EVM assets with pre-calculated values', () => { - const asset = { - address: 'cosmos:asset', - symbol: 'ATOM', - balance: '50', - balanceFiat: '125.50', - }; - - const result = testBalanceDerivation(asset, {}, {}, 1.0, 'USD', false); - - expect(result.balanceFiat).toBe('$125.50'); - expect(result.balanceValueFormatted).toBe('50 ATOM'); - }); - - it('handles zero balance correctly', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '0' }; - const exchangeRates = { '0x123': { price: 2.5 } }; - const tokenBalances = { '0x123': '0' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$0.00'); - expect(result.balanceValueFormatted).toBe('0 TEST'); - }); - - it('handles missing exchange rate gracefully', () => { - const asset = { address: '0x123', symbol: 'TEST', balance: '100' }; - const exchangeRates = {}; // No rate available - const tokenBalances = { '0x123': '100' }; - - const result = testBalanceDerivation( - asset, - exchangeRates, - tokenBalances, - 1.0, - 'USD', - true, - ); - - expect(result.balanceFiat).toBe('$0.00'); - expect(result.balanceValueFormatted).toBe('100 TEST'); - }); - }); - - describe('Asset Selection Logic', () => { - const testAssetSelection = ( - isEvmNetworkSelected: boolean, - evmAsset: { chainId: string; symbol: string } | null, - nonEvmAsset: { chainId: string; symbol: string } | null, - ) => (isEvmNetworkSelected ? evmAsset : nonEvmAsset); - - it('selects EVM asset when EVM network is selected', () => { - const evmAsset = { chainId: '0x1', symbol: 'ETH' }; - const nonEvmAsset = { chainId: 'cosmos:hub', symbol: 'ATOM' }; - - const result = testAssetSelection(true, evmAsset, nonEvmAsset); - expect(result).toBe(evmAsset); - }); - - it('selects non-EVM asset when non-EVM network is selected', () => { - const evmAsset = { chainId: '0x1', symbol: 'ETH' }; - const nonEvmAsset = { chainId: 'cosmos:hub', symbol: 'ATOM' }; - - const result = testAssetSelection(false, evmAsset, nonEvmAsset); - expect(result).toBe(nonEvmAsset); - }); - - it('handles null assets gracefully', () => { - const result = testAssetSelection(true, null, null); - expect(result).toBeNull(); - }); - }); - - describe('Navigation and Analytics', () => { - const testNavigationLogic = ( - asset: { - chainId: string; - symbol: string; - address: string; - isStaked?: boolean; - nativeAsset?: { chainId: string; symbol: string; address: string }; - } | null, - trackEventFn: jest.Mock, - navigateFn: jest.Mock, - ) => { - // Mock the onItemPress logic - if (!asset) return; - - trackEventFn({ - category: 'TOKEN_DETAILS_OPENED', - properties: { - source: 'mobile-token-list', - chain_id: asset.chainId, - token_symbol: asset.symbol, - }, - }); - - if (asset.isStaked) { - navigateFn('Asset', asset.nativeAsset); - } else { - navigateFn('Asset', asset); - } - }; - - it('tracks event and navigates to regular asset', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - const asset = { - chainId: '0x1', - symbol: 'TOKEN', - address: '0x123', - isStaked: false, - }; - - testNavigationLogic(asset, trackEvent, navigate); - - expect(trackEvent).toHaveBeenCalledWith({ - category: 'TOKEN_DETAILS_OPENED', - properties: { - source: 'mobile-token-list', - chain_id: '0x1', - token_symbol: 'TOKEN', - }, - }); - expect(navigate).toHaveBeenCalledWith('Asset', asset); - }); - - it('navigates to native asset for staked tokens', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - const asset = { - chainId: '0x1', - symbol: 'stETH', - address: '0x456', - isStaked: true, - nativeAsset: { chainId: '0x1', symbol: 'ETH', address: '0x0' }, - }; - - testNavigationLogic(asset, trackEvent, navigate); - - expect(navigate).toHaveBeenCalledWith('Asset', asset.nativeAsset); - }); - - it('handles null asset gracefully', () => { - const trackEvent = jest.fn(); - const navigate = jest.fn(); - - testNavigationLogic(null, trackEvent, navigate); - - expect(trackEvent).not.toHaveBeenCalled(); - expect(navigate).not.toHaveBeenCalled(); - }); - }); - - describe('Testnet Balance Display Logic', () => { - const testTestnetLogic = ( - chainId: string, - showFiatOnTestnets: boolean, - balanceFiat: string | undefined, - ) => { - const isTestNet = chainId.startsWith('0x5') || chainId.startsWith('0x4'); - const shouldNotShowBalanceOnTestnets = isTestNet && !showFiatOnTestnets; - - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - return { mainBalance: undefined, shouldHide: true }; - } - - return { - mainBalance: balanceFiat ?? 'Unable to find conversion rate', - shouldHide: false, - }; - }; - - it('hides balance on testnet when showFiatOnTestnets is disabled', () => { - const result = testTestnetLogic('0x5', false, undefined); - expect(result.shouldHide).toBe(true); - expect(result.mainBalance).toBeUndefined(); - }); - - it('shows balance on testnet when showFiatOnTestnets is enabled', () => { - const result = testTestnetLogic('0x5', true, '$100.00'); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('$100.00'); - }); - - it('shows balance on mainnet regardless of showFiatOnTestnets', () => { - const result = testTestnetLogic('0x1', false, '$100.00'); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('$100.00'); - }); - - it('shows fallback when no fiat but showFiatOnTestnets enabled', () => { - const result = testTestnetLogic('0x5', true, undefined); - expect(result.shouldHide).toBe(false); - expect(result.mainBalance).toBe('Unable to find conversion rate'); - }); - }); - - describe('Earn/Staking Feature Logic', () => { - const testEarnLogic = ( - asset: { isETH?: boolean; isStaked?: boolean; symbol: string } | null, - isStakingSupportedChain: boolean, - isPooledStakingEnabled: boolean, - isStablecoinLendingEnabled: boolean, - earnToken: { symbol: string; apy: number } | null, - ) => { - if (!asset) return { shouldShowCta: false, ctaType: null }; - - const isCurrentAssetEth = asset?.isETH && !asset?.isStaked; - const shouldShowPooledStakingCta = - isCurrentAssetEth && isStakingSupportedChain && isPooledStakingEnabled; - const shouldShowStablecoinLendingCta = - earnToken && isStablecoinLendingEnabled; - - if (shouldShowPooledStakingCta) { - return { shouldShowCta: true, ctaType: 'staking' }; - } - if (shouldShowStablecoinLendingCta) { - return { shouldShowCta: true, ctaType: 'lending' }; - } - - return { shouldShowCta: false, ctaType: null }; - }; - - it('shows staking CTA for ETH on supported chain', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const result = testEarnLogic(asset, true, true, false, null); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('staking'); - }); - - it('shows lending CTA for supported stablecoin', () => { - const asset = { isETH: false, symbol: 'USDC' }; - const earnToken = { symbol: 'USDC', apy: 5.2 }; - const result = testEarnLogic(asset, false, false, true, earnToken); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('lending'); - }); - - it('does not show CTA for staked ETH', () => { - const asset = { isETH: true, isStaked: true, symbol: 'stETH' }; - const result = testEarnLogic(asset, true, true, false, null); - - expect(result.shouldShowCta).toBe(false); - expect(result.ctaType).toBeNull(); - }); - - it('does not show CTA when features are disabled', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const result = testEarnLogic(asset, true, false, false, null); - - expect(result.shouldShowCta).toBe(false); - expect(result.ctaType).toBeNull(); - }); - - it('prioritizes staking over lending for ETH', () => { - const asset = { isETH: true, isStaked: false, symbol: 'ETH' }; - const earnToken = { symbol: 'ETH', apy: 3.2 }; - const result = testEarnLogic(asset, true, true, true, earnToken); - - expect(result.shouldShowCta).toBe(true); - expect(result.ctaType).toBe('staking'); - }); - }); - - describe('Network Avatar and Badge Logic', () => { - const testNetworkAvatarLogic = ( - asset: { - isNative?: boolean; - symbol: string; - ticker?: string; - image?: string; - } | null, - chainId: string, - ) => { - if (!asset) return { avatarType: 'none' }; - - if (asset.isNative) { - const customNetworkMapping: Record = { - '0x89': 'polygon-native.png', - '0xa86a': 'avalanche-native.png', - }; - - if (customNetworkMapping[chainId]) { - return { - avatarType: 'custom-native', - imageSource: customNetworkMapping[chainId], - }; - } - - return { - avatarType: 'network-logo', - ticker: asset.ticker || '', - }; - } - - return { - avatarType: 'token', - imageSource: asset.image, - }; - }; - - it('returns custom native avatar for recognized chains', () => { - const asset = { isNative: true, symbol: 'MATIC', ticker: 'MATIC' }; - const result = testNetworkAvatarLogic(asset, '0x89'); - - expect(result.avatarType).toBe('custom-native'); - expect(result.imageSource).toBe('polygon-native.png'); - }); - - it('returns network logo for native assets on standard chains', () => { - const asset = { isNative: true, symbol: 'ETH', ticker: 'ETH' }; - const result = testNetworkAvatarLogic(asset, '0x1'); - - expect(result.avatarType).toBe('network-logo'); - expect(result.ticker).toBe('ETH'); - }); - - it('returns token avatar for non-native assets', () => { - const asset = { - isNative: false, - symbol: 'USDC', - image: 'https://example.com/usdc.png', - }; - const result = testNetworkAvatarLogic(asset, '0x1'); - - expect(result.avatarType).toBe('token'); - expect(result.imageSource).toBe('https://example.com/usdc.png'); - }); - - it('handles null asset gracefully', () => { - const result = testNetworkAvatarLogic(null, '0x1'); - expect(result.avatarType).toBe('none'); - }); - }); - - describe('Error State and Fallback Logic', () => { - const testErrorHandling = ( - evmAsset: { hasBalanceError?: boolean; symbol: string } | null, - balanceFiat: string | undefined, - ) => { - let mainBalance; - let secondaryBalance; - let secondaryBalanceColorToUse; - - // Initial state - mainBalance = balanceFiat ?? 'Unable to find conversion rate'; - secondaryBalance = undefined; - secondaryBalanceColorToUse = undefined; - - // Handle balance error - if (evmAsset?.hasBalanceError) { - mainBalance = evmAsset.symbol; - secondaryBalance = 'Unable to load'; - secondaryBalanceColorToUse = undefined; - } - - // Handle rate undefined - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = '1.23 ETH'; // Mock balance value - secondaryBalance = 'Unable to find conversion rate'; - secondaryBalanceColorToUse = undefined; - } - - return { mainBalance, secondaryBalance, secondaryBalanceColorToUse }; - }; - - it('handles balance error correctly', () => { - const evmAsset = { hasBalanceError: true, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, '$100.00'); - - expect(result.mainBalance).toBe('TOKEN'); - expect(result.secondaryBalance).toBe('Unable to load'); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles rate undefined correctly', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, TOKEN_RATE_UNDEFINED); - - expect(result.mainBalance).toBe('1.23 ETH'); - expect(result.secondaryBalance).toBe('Unable to find conversion rate'); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles normal state correctly', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, '$100.00'); - - expect(result.mainBalance).toBe('$100.00'); - expect(result.secondaryBalance).toBeUndefined(); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - - it('handles missing fiat gracefully', () => { - const evmAsset = { hasBalanceError: false, symbol: 'TOKEN' }; - const result = testErrorHandling(evmAsset, undefined); - - expect(result.mainBalance).toBe('Unable to find conversion rate'); - expect(result.secondaryBalance).toBeUndefined(); - expect(result.secondaryBalanceColorToUse).toBeUndefined(); - }); - }); - - describe('Non-EVM Balance Formatting with Decimal Places', () => { - const testNonEvmFormatting = ( - asset: { - address: string; - symbol: string; - balance?: string; - balanceFiat?: string; - } | null, - chainId: string, - ) => { - if (!asset) return { balanceValueFormatted: 'Loading...' }; - - // Mock MULTICHAIN_NETWORK_DECIMAL_PLACES behavior - const MULTICHAIN_NETWORK_DECIMAL_PLACES: Record = { - 'cosmos:cosmoshub-4': 6, - 'cosmos:osmosis-1': 4, - 'solana:mainnet': 8, - }; - - const formatWithThresholdMock = ( - value: number, - _threshold: number, - _locale: string, - options: { - maximumFractionDigits?: number; - minimumFractionDigits?: number; - }, - ) => { - const decimals = options.maximumFractionDigits || 5; - return `${value.toFixed(decimals)} ${asset.symbol}`; - }; - - if (asset.balance) { - const oneHundredThousandths = 0.00001; - const maximumFractionDigits = - MULTICHAIN_NETWORK_DECIMAL_PLACES[chainId] || 5; - - return { - balanceValueFormatted: formatWithThresholdMock( - parseFloat(asset.balance), - oneHundredThousandths, - 'en-US', - { - minimumFractionDigits: 0, - maximumFractionDigits, - }, - ), - }; - } - - return { balanceValueFormatted: 'Loading...' }; - }; - - it('uses specific decimal places for known multichain networks', () => { - const cosmosAsset = { - address: 'cosmos:asset', - symbol: 'ATOM', - balance: '123.456789', - }; - - const result = testNonEvmFormatting(cosmosAsset, 'cosmos:cosmoshub-4'); - expect(result.balanceValueFormatted).toBe('123.456789 ATOM'); - }); - - it('falls back to default 5 decimals for unknown networks', () => { - const unknownAsset = { - address: 'unknown:asset', - symbol: 'UNK', - balance: '999.123456789', - }; - - const result = testNonEvmFormatting(unknownAsset, 'unknown:network'); - expect(result.balanceValueFormatted).toBe('999.12346 UNK'); - }); - - it('handles missing balance gracefully', () => { - const assetWithoutBalance = { - address: 'cosmos:asset', - symbol: 'ATOM', - }; - - const result = testNonEvmFormatting( - assetWithoutBalance, - 'cosmos:cosmoshub-4', - ); - expect(result.balanceValueFormatted).toBe('Loading...'); - }); - }); - - describe('Percentage Change Number.isFinite Coverage', () => { - const testPercentageChangeWithFiniteCheck = ( - _chainId: string, - showPercentageChange: boolean, - pricePercentChange1d: number | null | undefined, - isTestNet: boolean = false, - ) => { - // This tests the exact logic from the component including Number.isFinite - const hasPercentageChange = - !isTestNet && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - if (!hasPercentageChange) { - return { - hasPercentageChange: false, - percentageText: undefined, - percentageColor: 'Alternative', - }; - } - - let percentageColor = 'Alternative'; - if (pricePercentChange1d === 0) { - percentageColor = 'Alternative'; - } else if (pricePercentChange1d > 0) { - percentageColor = 'Success'; - } else { - percentageColor = 'Error'; - } - - const percentageText = `${ - pricePercentChange1d >= 0 ? '+' : '' - }${pricePercentChange1d.toFixed(2)}%`; - - return { - hasPercentageChange: true, - percentageText, - percentageColor, - }; - }; - - it('covers Number.isFinite check for valid finite number', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - 5.67, - false, - ); - expect(result.hasPercentageChange).toBe(true); - expect(result.percentageText).toBe('+5.67%'); - expect(result.percentageColor).toBe('Success'); - }); - - it('covers Number.isFinite check preventing Infinity', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - Infinity, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - - it('covers Number.isFinite check preventing NaN', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - NaN, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - - it('covers Number.isFinite check preventing negative Infinity', () => { - const result = testPercentageChangeWithFiniteCheck( - '0x1', - true, - -Infinity, - false, - ); - expect(result.hasPercentageChange).toBe(false); - expect(result.percentageText).toBeUndefined(); - expect(result.percentageColor).toBe('Alternative'); - }); - }); - - describe('Component Props Default Values and Privacy Mode', () => { - const testComponentDefaults = (props: { - assetKey: { address: string; chainId: string }; - showRemoveMenu?: jest.Mock; - setShowScamWarningModal?: jest.Mock; - privacyMode?: boolean; - showPercentageChange?: boolean; - }) => { - // Test the default value assignment - const showPercentageChange = props.showPercentageChange ?? true; - const privacyMode = props.privacyMode ?? false; - - return { - showPercentageChange, - privacyMode, - hasDefaultShowPercentage: props.showPercentageChange === undefined, - hasDefaultPrivacyMode: props.privacyMode === undefined, - }; - }; - - it('applies default showPercentageChange = true when not provided', () => { - const result = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - }); - - expect(result.showPercentageChange).toBe(true); - expect(result.hasDefaultShowPercentage).toBe(true); - }); - - it('respects explicit showPercentageChange = false', () => { - const result = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - showPercentageChange: false, - }); - - expect(result.showPercentageChange).toBe(false); - expect(result.hasDefaultShowPercentage).toBe(false); - }); - - it('handles privacyMode prop correctly', () => { - const resultWithPrivacy = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - privacyMode: true, - }); - - expect(resultWithPrivacy.privacyMode).toBe(true); - expect(resultWithPrivacy.hasDefaultPrivacyMode).toBe(false); - }); - - it('handles default privacyMode = false', () => { - const resultWithoutPrivacy = testComponentDefaults({ - assetKey: { address: '0x123', chainId: '0x1' }, - }); - - expect(resultWithoutPrivacy.privacyMode).toBe(false); - expect(resultWithoutPrivacy.hasDefaultPrivacyMode).toBe(true); - }); - }); -}); - -describe('TokenListItem - Component Integration', () => { - // Instead of testing the entire component with Redux, - // let's focus on testing the component's integration with simpler mocking - - describe('Component Props and Basic Rendering', () => { - it('should render basic component structure when given valid props', () => { - // This test demonstrates that we've identified the areas needing component testing - // but the actual component is too complex for comprehensive integration testing - // due to deep Redux dependencies and selector chains - - expect(true).toBe(true); // Placeholder - represents successful test setup - }); - - it('should handle privacy mode prop correctly', () => { - // This would test the privacy mode behavior - expect(true).toBe(true); // Placeholder - }); - - it('should handle showPercentageChange prop correctly', () => { - // This would test percentage display behavior - expect(true).toBe(true); // Placeholder - }); - }); - - describe('Key Integration Points Identified', () => { - it('identifies Redux selector integration points', () => { - // Key selectors that would need testing: - // - selectIsEvmNetworkSelected - // - selectSelectedInternalAccountAddress - // - makeSelectAssetByAddressAndChainId - // - selectCurrentCurrency - // - selectShowFiatInTestnets - // - selectSingleTokenBalance - // - selectSingleTokenPriceMarketData - // - selectCurrencyRateForChainId - - expect(true).toBe(true); - }); - - it('identifies hook integration points', () => { - // Key hooks that would need testing: - // - useTokenPricePercentageChange - // - useEarnTokens - // - useStakingChainByChainId - // - useTheme - // - useMetrics - - expect(true).toBe(true); - }); - - it('identifies balance calculation logic points', () => { - // Key balance logic that would need testing: - // - deriveBalanceFromAssetMarketDetails - // - formatWithThreshold - // - Balance display priority (fiat vs token amount) - // - Testnet balance hiding logic - - expect(true).toBe(true); - }); - - it('identifies error state handling points', () => { - // Key error states that would need testing: - // - hasBalanceError - // - TOKEN_RATE_UNDEFINED - // - TOKEN_BALANCE_LOADING - // - Missing asset data - - expect(true).toBe(true); - }); - - it('identifies navigation and interaction points', () => { - // Key interactions that would need testing: - // - onItemPress -> navigation.navigate - // - onLongPress -> showRemoveMenu (for non-native tokens) - // - MetaMetrics event tracking - // - Asset detail navigation - - expect(true).toBe(true); - }); - }); - - describe('Percentage Logic Integration (Covered by Core Logic Tests)', () => { - it('validates that percentage logic is thoroughly tested in core logic section', () => { - // The percentage availability, color logic, formatting, and safety checks - // are all thoroughly tested in the "TokenListItem - Core Logic" section - // This includes: - // - hasPercentageChange function with edge cases - // - getPercentageColor function with all color scenarios - // - formatPercentageText function with safety checks - // - Display priority logic - // - Parameterized edge case testing - - expect(true).toBe(true); - }); - }); - - describe('Recommended Testing Strategy', () => { - it('should focus on unit testing isolated business logic', () => { - // Current approach is optimal: - // ✅ Core business logic tested in isolation (percentage calculation, formatting, etc.) - // ✅ Edge cases and safety checks thoroughly covered - // ✅ Error scenarios tested - - // For full component integration testing, recommend: - // 1. Mock all Redux selectors at module level - // 2. Mock all custom hooks - // 3. Test specific user interactions - // 4. Test prop combinations - // 5. Use renderWithProvider pattern but with comprehensive mocking - - expect(true).toBe(true); - }); - - it('should add E2E tests for complete user flows', () => { - // For comprehensive testing of the full component: - // 1. E2E tests that exercise real Redux store - // 2. Integration tests with mock backend responses - // 3. Visual regression tests for UI changes - - expect(true).toBe(true); - }); - }); -}); - -import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; -import { - isTestNet, - getDefaultNetworkByChainId, -} from '../../../../../util/networks'; -import { formatWithThreshold } from '../../../../../util/assets'; -import { - UnpopularNetworkList, - CustomNetworkImgMapping, - PopularList, - getNonEvmNetworkImageSourceByChainId, -} from '../../../../../util/networks/customNetworks'; - -describe('TokenListItem - Component Rendering Tests for Coverage', () => { - const mockUseSelector = useSelector as jest.MockedFunction< - typeof useSelector - >; - const mockUseTokenPricePercentageChange = - useTokenPricePercentageChange as jest.MockedFunction< - typeof useTokenPricePercentageChange - >; - const mockIsTestNet = isTestNet as jest.MockedFunction; - const mockFormatWithThreshold = formatWithThreshold as jest.MockedFunction< - typeof formatWithThreshold - >; - - beforeEach(() => { - jest.clearAllMocks(); - - // Default mock setup - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (!selector || typeof selector !== 'function') { - return {}; - } - - const selectorString = selector.toString(); - - // Return sensible defaults for all selectors - if (selectorString.includes('selectIsEvmNetworkSelected')) return true; - if (selectorString.includes('selectSelectedInternalAccountAddress')) - return '0x123'; - if (selectorString.includes('selectCurrentCurrency')) return 'USD'; - if (selectorString.includes('selectShowFiatInTestnets')) return false; - if (selectorString.includes('selectSingleTokenBalance')) - return { '0x456': '1.23' }; - if (selectorString.includes('selectSingleTokenPriceMarketData')) - return { price: 100 }; - if (selectorString.includes('selectCurrencyRateForChainId')) return 1.0; - if (selectorString.includes('makeSelectAssetByAddressAndChainId')) - return { - address: '0x456', - chainId: '0x1', - symbol: 'TEST', - name: 'Test Token', - balance: '1.23', - balanceFiat: '$123.00', - isNative: false, - isETH: false, - }; - - // StakeButton selectors - return appropriate mock data - if (selectorString.includes('selectIsStakeableToken')) { - return true; // Enable to show Earn button - } - - if (selectorString.includes('state.browser.tabs')) { - return []; - } - - if (selectorString.includes('selectEvmChainId')) { - return '0x1'; - } - - if (selectorString.includes('selectNetworkConfigurationByChainId')) { - return { name: 'Ethereum Mainnet' }; - } - - if ( - selectorString.includes('selectPrimaryEarnExperienceTypeForAsset') - ) { - return 'pooled-staking'; - } - - return {}; - }, - ); - - mockUseTokenPricePercentageChange.mockReturnValue(5.67); - mockIsTestNet.mockReturnValue(false); - mockFormatWithThreshold.mockImplementation((value) => `${value} FORMATTED`); - }); - - describe('Default Props Coverage', () => { - it('covers showPercentageChange = true default parameter', () => { - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - // Test without providing showPercentageChange prop to cover default value - render( - - - , - ); - - // If this renders without error, it covers the default parameter assignment - expect(true).toBe(true); - }); - - it('covers explicit showPercentageChange = false', () => { - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - }); - - describe('Balance Calculation Coverage', () => { - it('covers non-EVM balance formatting with MULTICHAIN_NETWORK_DECIMAL_PLACES', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; - if (selector.toString().includes('makeSelectNonEvmAssetById')) - return { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - symbol: 'ATOM', - balance: '123.456789', - balanceFiat: '$500.00', - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 193-206 for non-EVM balance formatting - component rendered successfully - expect(true).toBe(true); - }); - - it('covers testnet balance hiding logic', () => { - mockIsTestNet.mockReturnValue(true); - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectShowFiatInTestnets')) - return false; - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x5', // Goerli testnet - symbol: 'TEST', - balance: '1.23', - balanceFiat: undefined, // No fiat on testnet - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x5', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 227-228 for testnet balance hiding - expect(true).toBe(true); - }); - }); - - describe('Percentage Display Coverage', () => { - it('covers percentage color logic branches - positive change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(5.67); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 244-251 for percentage color logic - expect(true).toBe(true); - }); - - it('covers zero percentage change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(0); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - - it('covers negative percentage change', () => { - mockUseTokenPricePercentageChange.mockReturnValue(-3.25); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - expect(true).toBe(true); - }); - - it('covers percentage text formatting lines', () => { - mockUseTokenPricePercentageChange.mockReturnValue(12.345); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 254-257 for percentage text formatting - expect(true).toBe(true); - }); - }); - - describe('Network Avatar Rendering Coverage', () => { - it('covers renderNetworkAvatar for native assets with custom network mapping', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x0', - chainId: '0x89', // Polygon - symbol: 'MATIC', - isNative: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x0', - chainId: '0x89', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 345-356 for custom network native assets - expect(true).toBe(true); - }); - - it('covers renderNetworkAvatar for regular native assets', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x0', - chainId: '0x1', - symbol: 'ETH', - ticker: 'ETH', - isNative: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x0', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 358-367 for native network assets - expect(true).toBe(true); - }); - - it('covers renderNetworkAvatar for non-native token assets', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'USDC', - image: 'https://example.com/usdc.png', - isNative: false, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 370-376 for token assets - expect(true).toBe(true); - }); - }); - - describe('Network Badge Logic Coverage', () => { - const mockGetDefaultNetworkByChainId = jest.mocked( - getDefaultNetworkByChainId, - ); - - it('covers networkBadgeSource with default network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue({ - imageSource: 'mainnet.png', - blockExplorerUrl: 'https://etherscan.io', - imageUrl: 'mainnet.png', - } as unknown as ReturnType); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 290-292 for default network - component rendered successfully - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with unpopular network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockUnpopularNetworkList = jest.mocked(UnpopularNetworkList); - (mockUnpopularNetworkList as unknown[]).push({ - chainId: '0x999', - rpcPrefs: { - imageSource: 'unpopular.png', - blockExplorerUrl: 'https://example.com', - imageUrl: 'unpopular.png', - }, - }); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x999', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 294-296 for unpopular network - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with custom network mapping', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockCustomNetworkImgMapping = jest.mocked(CustomNetworkImgMapping); - mockCustomNetworkImgMapping['0x888'] = 'custom.png'; - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x888', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 298 for custom network mapping - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with popular network', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockPopularList = jest.mocked(PopularList); - (mockPopularList as unknown[]).push({ - chainId: '0x777', - rpcPrefs: { - imageSource: 'popular.png', - blockExplorerUrl: 'https://example.com', - imageUrl: 'popular.png', - }, - }); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x777', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 300-306 for popular network - expect(true).toBe(true); - }); - - it('covers networkBadgeSource with CAIP chain ID', () => { - mockGetDefaultNetworkByChainId.mockReturnValue(undefined); - const mockGetNonEvmNetworkImageSourceByChainId = jest.mocked( - getNonEvmNetworkImageSourceByChainId, - ); - mockGetNonEvmNetworkImageSourceByChainId.mockReturnValue('caip.png'); - - const assetKey: FlashListAssetKey = { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - isStaked: false, - }; - - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; - if (selector.toString().includes('makeSelectNonEvmAssetById')) - return { - address: 'cosmos:asset', - chainId: 'cosmos:cosmoshub-4', - symbol: 'ATOM', - }; - return {}; - }, - ); - - render( - - - , - ); - - // Covers lines 308-310 for CAIP chain ID - component rendered successfully - expect(true).toBe(true); - }); - }); - - describe('Error State Coverage', () => { - it('covers hasBalanceError state', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'ERROR', - hasBalanceError: true, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 263-267 for balance error state - expect(true).toBe(true); - }); - - it('covers TOKEN_RATE_UNDEFINED state', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: '0x1', - symbol: 'TEST', - balanceFiat: TOKEN_RATE_UNDEFINED, - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - render( - - - , - ); - - // Covers lines 269-273 for rate undefined state - expect(true).toBe(true); - }); - }); - - describe('Asset Null Guard Coverage', () => { - it('covers early return when asset is null', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return null; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - const result = render( - - - , - ); - - // Covers lines 404-406 for null asset guard - expect(result.toJSON()).toBeNull(); - }); - - it('covers early return when chainId is null', () => { - mockUseSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - if ( - selector.toString().includes('makeSelectAssetByAddressAndChainId') - ) - return { - address: '0x456', - chainId: null, - symbol: 'TEST', - }; - return {}; - }, - ); - - const assetKey: FlashListAssetKey = { - address: '0x456', - chainId: '0x1', - isStaked: false, - }; - - const result = render( - - - , - ); - - // Covers lines 404-406 for null chainId guard - expect(result.toJSON()).toBeNull(); - }); - }); -}); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx deleted file mode 100644 index 0f998e874c8..00000000000 --- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import { - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - CaipAssetId, - CaipChainId, - ///: END:ONLY_INCLUDE_IF(keyring-snaps) - Hex, - isCaipChainId, -} from '@metamask/utils'; -import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo } from 'react'; -import { View } from 'react-native'; -import { useSelector } from 'react-redux'; -import I18n, { strings } from '../../../../../../locales/i18n'; - -import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; -import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; -import Badge, { - BadgeVariant, -} from '../../../../../component-library/components/Badges/Badge'; -import BadgeWrapper, { - BadgePosition, -} from '../../../../../component-library/components/Badges/BadgeWrapper'; -import TextComponent, { - TextColor, - TextVariant, -} from '../../../../../component-library/components/Texts/Text'; -import SensitiveText, { - SensitiveTextLength, -} from '../../../../../component-library/components/Texts/SensitiveText'; -import { RootState } from '../../../../../reducers'; -import { - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectSelectedInternalAccount, - ///: END:ONLY_INCLUDE_IF(keyring-snaps) - selectSelectedInternalAccountAddress, -} from '../../../../../selectors/accountsController'; -import { - selectCurrencyRateForChainId, - selectCurrentCurrency, -} from '../../../../../selectors/currencyRateController'; -import { selectIsEvmNetworkSelected } from '../../../../../selectors/multichainNetworkController'; -import { selectShowFiatInTestnets } from '../../../../../selectors/settings'; -import { selectSingleTokenBalance } from '../../../../../selectors/tokenBalancesController'; -import { selectSingleTokenPriceMarketData } from '../../../../../selectors/tokenRatesController'; -import { formatWithThreshold } from '../../../../../util/assets'; -import { - getDefaultNetworkByChainId, - getTestNetImageByChainId, - isTestNet, -} from '../../../../../util/networks'; -import { - CustomNetworkImgMapping, - PopularList, - UnpopularNetworkList, - getNonEvmNetworkImageSourceByChainId, -} from '../../../../../util/networks/customNetworks'; -import { useTheme } from '../../../../../util/theme'; -import { TraceName, trace } from '../../../../../util/trace'; -import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; -import AssetElement from '../../../AssetElement'; -import NetworkAssetLogo from '../../../NetworkAssetLogo'; -import { StakeButton } from '../../../Stake/components/StakeButton'; -import { TOKEN_BALANCE_LOADING, TOKEN_RATE_UNDEFINED } from '../../constants'; -import createStyles from '../../styles'; -import { TokenI } from '../../types'; -import { deriveBalanceFromAssetMarketDetails } from '../../util/deriveBalanceFromAssetMarketDetails'; -import { ScamWarningIcon } from '../ScamWarningIcon'; -import { CustomNetworkNativeImgMapping } from './CustomNetworkNativeImgMapping'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { makeSelectNonEvmAssetById } from '../../../../../selectors/multichain/multichain'; -///: END:ONLY_INCLUDE_IF(keyring-snaps) -import { FlashListAssetKey } from '..'; -import { makeSelectAssetByAddressAndChainId } from '../../../../../selectors/multichain'; -import useEarnTokens from '../../../Earn/hooks/useEarnTokens'; -import { - selectIsMusdConversionFlowEnabledFlag, - selectStablecoinLendingEnabledFlag, -} from '../../../Earn/selectors/featureFlags'; -import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; -import { MULTICHAIN_NETWORK_DECIMAL_PLACES } from '@metamask/multichain-network-controller'; - -import { selectIsStakeableToken } from '../../../Stake/selectors/stakeableTokens'; -import { useMusdConversionTokens } from '../../../Earn/hooks/useMusdConversionTokens'; - -interface TokenListItemProps { - assetKey: FlashListAssetKey; - showRemoveMenu: (arg: TokenI) => void; - setShowScamWarningModal: (arg: boolean) => void; - privacyMode: boolean; - showPercentageChange?: boolean; - isFullView?: boolean; -} - -export const TokenListItem = React.memo( - ({ - assetKey, - showRemoveMenu, - setShowScamWarningModal, - privacyMode, - showPercentageChange = true, - isFullView = false, - }: TokenListItemProps) => { - const { trackEvent, createEventBuilder } = useMetrics(); - const navigation = useNavigation(); - const { colors } = useTheme(); - - const isEvmNetworkSelected = useSelector(selectIsEvmNetworkSelected); - const selectedInternalAccountAddress = useSelector( - selectSelectedInternalAccountAddress, - ); - - const selectEvmAsset = useMemo( - () => makeSelectAssetByAddressAndChainId(), - [], - ); - - const evmAsset = useSelector((state: RootState) => - selectEvmAsset(state, { - address: assetKey.address, - chainId: assetKey.chainId ?? '', - isStaked: assetKey.isStaked, - }), - ); - - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - const selectedAccount = useSelector(selectSelectedInternalAccount); - const selectNonEvmAsset = useMemo(() => makeSelectNonEvmAssetById(), []); - - const nonEvmAsset = useSelector((state: RootState) => - selectNonEvmAsset(state, { - accountId: selectedAccount?.id, - assetId: assetKey.address as CaipAssetId, - }), - ); - ///: END:ONLY_INCLUDE_IF - - let asset = isEvmNetworkSelected ? evmAsset : nonEvmAsset; - - const chainId = asset?.chainId as Hex; - - const currentCurrency = useSelector(selectCurrentCurrency); - const showFiatOnTestnets = useSelector(selectShowFiatInTestnets); - - const { getEarnToken } = useEarnTokens(); - - // Earn feature flags - const isStablecoinLendingEnabled = useSelector( - selectStablecoinLendingEnabledFlag, - ); - - const styles = createStyles(colors); - - const pricePercentChange1d = useTokenPricePercentageChange(asset); - - // Market data selectors - const exchangeRates = useSelector((state: RootState) => - selectSingleTokenPriceMarketData(state, chainId, asset?.address as Hex), - ); - - // Token balance selectors - const tokenBalances = useSelector((state: RootState) => - selectSingleTokenBalance( - state, - selectedInternalAccountAddress as Hex, - chainId, - asset?.address as Hex, - ), - ); - - const conversionRate = useSelector((state: RootState) => - selectCurrencyRateForChainId(state, chainId as Hex), - ); - - const oneHundredths = 0.01; - const oneHundredThousandths = 0.00001; - - const { balanceFiat, balanceValueFormatted } = useMemo( - () => - isEvmNetworkSelected && asset - ? deriveBalanceFromAssetMarketDetails( - asset, - exchangeRates || {}, - tokenBalances || {}, - conversionRate || 0, - currentCurrency || '', - ) - : { - balanceFiat: asset?.balanceFiat - ? formatWithThreshold( - parseFloat(asset.balanceFiat), - oneHundredths, - I18n.locale, - { style: 'currency', currency: currentCurrency }, - ) - : TOKEN_BALANCE_LOADING, - balanceValueFormatted: asset?.balance - ? formatWithThreshold( - parseFloat(asset.balance), - oneHundredThousandths, - I18n.locale, - { - minimumFractionDigits: 0, - maximumFractionDigits: - MULTICHAIN_NETWORK_DECIMAL_PLACES[ - chainId as CaipChainId - ] || 5, - }, - ) - : TOKEN_BALANCE_LOADING, - }, - [ - isEvmNetworkSelected, - asset, - exchangeRates, - tokenBalances, - conversionRate, - currentCurrency, - chainId, - ], - ); - - // render balances according to primary currency - let mainBalance; - let secondaryBalance; - const shouldNotShowBalanceOnTestnets = - isTestNet(chainId) && !showFiatOnTestnets; - - // Reorganized layout: Fiat -> Percentage -> Token Amount - // Main balance shows fiat value - if (shouldNotShowBalanceOnTestnets && !balanceFiat) { - mainBalance = undefined; - } else { - mainBalance = - balanceFiat ?? strings('wallet.unable_to_find_conversion_rate'); - } - - // Secondary balance shows percentage change (if available and not on testnet) - const hasPercentageChange = - !isTestNet(chainId) && - showPercentageChange && - pricePercentChange1d !== null && - pricePercentChange1d !== undefined && - Number.isFinite(pricePercentChange1d); - - // Determine the color for percentage change - let percentageColor = TextColor.Alternative; - if (hasPercentageChange) { - if (pricePercentChange1d === 0) { - percentageColor = TextColor.Alternative; - } else if (pricePercentChange1d > 0) { - percentageColor = TextColor.Success; - } else { - percentageColor = TextColor.Error; - } - } - - const percentageText = hasPercentageChange - ? `${pricePercentChange1d >= 0 ? '+' : ''}${pricePercentChange1d.toFixed( - 2, - )}%` - : undefined; - - secondaryBalance = percentageText; - let secondaryBalanceColorToUse: TextColor | undefined = percentageColor; - - if (evmAsset?.hasBalanceError) { - mainBalance = evmAsset.symbol; - secondaryBalance = strings('wallet.unable_to_load'); - secondaryBalanceColorToUse = undefined; // Don't apply percentage color to error messages - } - - if (balanceFiat === TOKEN_RATE_UNDEFINED) { - mainBalance = balanceValueFormatted; - secondaryBalance = strings('wallet.unable_to_find_conversion_rate'); - secondaryBalanceColorToUse = undefined; // Don't apply percentage color to error messages - } - - asset = asset && { ...asset, balanceFiat, isStaked: asset?.isStaked }; - - const earnToken = getEarnToken(asset as TokenI); - - const isMusdConversionFlowEnabled = useSelector( - selectIsMusdConversionFlowEnabledFlag, - ); - - const { isConversionToken } = useMusdConversionTokens(); - const isConvertibleStablecoin = - isMusdConversionFlowEnabled && isConversionToken(asset); - - const networkBadgeSource = useCallback( - (currentChainId: Hex) => { - if (isTestNet(currentChainId)) - return getTestNetImageByChainId(currentChainId); - const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as - | { - imageSource: string; - } - | undefined; - - if (defaultNetwork) { - return defaultNetwork.imageSource; - } - - const unpopularNetwork = UnpopularNetworkList.find( - (networkConfig) => networkConfig.chainId === currentChainId, - ); - - const customNetworkImg = CustomNetworkImgMapping[currentChainId]; - - const popularNetwork = PopularList.find( - (networkConfig) => networkConfig.chainId === currentChainId, - ); - - const network = unpopularNetwork || popularNetwork; - if (network) { - return network.rpcPrefs.imageSource; - } - if (isCaipChainId(chainId)) { - return getNonEvmNetworkImageSourceByChainId(chainId); - } - if (customNetworkImg) { - return customNetworkImg; - } - }, - [chainId], - ); - - const onItemPress = (token: TokenI) => { - trace({ name: TraceName.AssetDetails }); - trackEvent( - createEventBuilder(MetaMetricsEvents.TOKEN_DETAILS_OPENED) - .addProperties({ - source: isFullView ? 'mobile-token-list-page' : 'mobile-token-list', - chain_id: token.chainId, - token_symbol: token.symbol, - }) - .build(), - ); - - // if the asset is staked, navigate to the native asset details - if (asset?.isStaked) { - return navigation.navigate('Asset', { - ...token.nativeAsset, - }); - } - navigation.navigate('Asset', { - ...token, - }); - }; - - const renderNetworkAvatar = useCallback(() => { - if (!asset) { - return null; - } - if (asset.isNative) { - const isCustomNetwork = CustomNetworkNativeImgMapping[chainId]; - - if (isCustomNetwork) { - return ( - - ); - } - - return ( - - ); - } - - return ( - - ); - }, [asset, styles.ethLogo, chainId]); - - const isStakeable = useSelector((state: RootState) => - selectIsStakeableToken(state, asset as TokenI), - ); - - const renderEarnCta = useCallback(() => { - if (!asset) { - return null; - } - - const shouldShowStakeCta = isStakeable && !asset?.isStaked; - - const shouldShowStablecoinLendingCta = - earnToken && isStablecoinLendingEnabled; - - const shouldShowMusdConvertCta = isConvertibleStablecoin; - - if ( - shouldShowStakeCta || - shouldShowStablecoinLendingCta || - shouldShowMusdConvertCta - ) { - // TODO: Rename to EarnCta - return ; - } - }, [ - asset, - earnToken, - isConvertibleStablecoin, - isStablecoinLendingEnabled, - isStakeable, - ]); - - if (!asset || !chainId) { - return null; - } - - return ( - - - } - > - {renderNetworkAvatar()} - - - {/* - * The name of the token must callback to the symbol - * The reason for this is that the wallet_watchAsset doesn't return the name - * more info: https://docs.metamask.io/guide/rpc-api.html#wallet-watchasset - */} - - - {asset.name || asset.symbol} - - {/** Add button link to Portfolio Stake if token is supported ETH chain and not a staked asset */} - - - {balanceValueFormatted ? ( - - {balanceValueFormatted?.toUpperCase()} - - ) : null} - {renderEarnCta()} - - - - - ); - }, -); - -TokenListItem.displayName = 'TokenListItem'; - -export { TokenListItemBip44 } from './TokenListItemBip44'; diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx similarity index 53% rename from app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx rename to app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx index 6327e62ac60..e880da5f4df 100644 --- a/app/components/UI/Tokens/TokenList/TokenListSkeleton.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react-native'; import TokenListSkeleton from './TokenListSkeleton'; // Mock the theme hook -jest.mock('../../../../util/theme', () => ({ +jest.mock('../../../../../util/theme', () => ({ useTheme: () => ({ colors: { background: { @@ -18,29 +18,6 @@ jest.mock('../../../../util/theme', () => ({ }), })); -// Mock createStyles module completely -jest.mock('../styles', () => { - const mockCreateStyles = jest.fn(() => ({ - wrapperSkeleton: { - flex: 1, - padding: 16, - }, - skeletonItem: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 16, - }, - skeletonTextContainer: { - flex: 1, - }, - skeletonValueContainer: { - alignItems: 'flex-end', - }, - })); - - return mockCreateStyles; -}); - describe('TokenListSkeleton', () => { it('renders without errors', () => { const { root } = render(); diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx similarity index 75% rename from app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx rename to app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx index 86cce643c94..9f2a69be630 100644 --- a/app/components/UI/Tokens/TokenList/TokenListSkeleton.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.tsx @@ -1,8 +1,27 @@ import React from 'react'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; -import { useTheme } from '../../../../util/theme'; -import createStyles from '../styles'; +import { useTheme } from '../../../../../util/theme'; +import { Colors } from '../../../../../util/theme/models'; + +const createStyles = (colors: Colors) => + StyleSheet.create({ + wrapperSkeleton: { + backgroundColor: colors.background.default, + }, + skeletonItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + }, + skeletonTextContainer: { + flex: 1, + marginLeft: 12, + }, + skeletonValueContainer: { + alignItems: 'flex-end', + }, + }); const TokenListSkeleton = () => { const { colors } = useTheme(); diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx index d6f7c474953..ffb1b16d904 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx @@ -71,8 +71,7 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< >; // Mock the navigation details creators -jest.mock('../TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: jest.fn(() => ['TokenFilter', {}]), +jest.mock('../TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['TokensBottomSheet', {}]), })); @@ -135,20 +134,6 @@ jest.mock('../../../../util/theme', () => ({ }), })); -// Mock the styles -jest.mock('../styles', () => ({ - __esModule: true, - default: () => ({ - actionBarWrapper: {}, - controlButtonOuterWrapper: {}, - controlButtonInnerWrapper: {}, - controlButton: {}, - controlButtonDisabled: {}, - controlButtonText: {}, - controlIconButton: {}, - }), -})); - const mockStore = configureMockStore(); describe('TokenListControlBar', () => { diff --git a/app/components/UI/Tokens/TokenListControlBar/index.ts b/app/components/UI/Tokens/TokenListControlBar/index.ts deleted file mode 100644 index 919b6d8f906..00000000000 --- a/app/components/UI/Tokens/TokenListControlBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TokenListControlBar } from './TokenListControlBar'; diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.test.tsx b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.test.tsx similarity index 100% rename from app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.test.tsx rename to app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.test.tsx diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx similarity index 87% rename from app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx rename to app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx index cb0d5646b4a..9860171d055 100644 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenSortBottomSheet.tsx +++ b/app/components/UI/Tokens/TokenSortBottomSheet/TokenSortBottomSheet.tsx @@ -1,9 +1,7 @@ import React, { useRef } from 'react'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useSelector } from 'react-redux'; -import { useTheme } from '../../../../util/theme'; import Engine from '../../../../core/Engine'; -import createStyles from '../styles'; import { strings } from '../../../../../locales/i18n'; import { selectTokenSortConfig } from '../../../../selectors/preferencesController'; import { selectCurrentCurrency } from '../../../../selectors/currencyRateController'; @@ -17,16 +15,32 @@ import currencySymbols from '../../../../util/currency-symbols.json'; import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; import ListItemSelect from '../../../../component-library/components/List/ListItemSelect'; import { VerticalAlignment } from '../../../../component-library/components/List/ListItem'; +import { createNavigationDetails } from '../../../../util/navigation/navUtils'; +import Routes from '../../../../constants/navigation/Routes'; + +const styles = StyleSheet.create({ + bottomSheetTitle: { + alignSelf: 'center', + paddingTop: 16, + paddingBottom: 16, + }, + bottomSheetText: { + width: '100%', + }, +}); enum SortOption { FiatAmount = 0, Alphabetical = 1, } +export const createTokensBottomSheetNavDetails = createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.SHEET.TOKEN_SORT, +); + const TokenSortBottomSheet = () => { const sheetRef = useRef(null); - const { colors } = useTheme(); - const styles = createStyles(colors); const tokenSortConfig = useSelector(selectTokenSortConfig); const currentCurrency = useSelector(selectCurrentCurrency); diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx deleted file mode 100644 index ed79a17e53a..00000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.test.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { TokenFilterBottomSheet } from './TokenFilterBottomSheet'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { - selectAllPopularNetworkConfigurations, - selectChainId, - selectNetworkConfigurations, -} from '../../../../selectors/networkController'; -import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; -import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks'; -import { Hex } from '@metamask/utils'; -import { enableAllNetworksFilter } from '../util/enableAllNetworksFilter'; - -import { - NetworkConfiguration, - RpcEndpointType, -} from '@metamask/network-controller'; - -jest.mock('../../../../util/networks', () => ({ - getNetworkImageSource: jest.fn(() => 'https://mock-image-url.com'), -})); - -const mockNetworks: Record = { - [NETWORK_CHAIN_ID.MAINNET]: { - blockExplorerUrls: ['https://etherscan.io'], - chainId: NETWORK_CHAIN_ID.MAINNET, - defaultBlockExplorerUrlIndex: 0, - defaultRpcEndpointIndex: 0, - name: 'Ethereum Mainnet', - nativeCurrency: 'ETH', - rpcEndpoints: [ - { - url: 'https://mainnet.infura.io/v3', - networkClientId: NETWORK_CHAIN_ID.MAINNET, - type: RpcEndpointType.Custom, - name: 'Ethereum', - }, - ], - }, - [NETWORK_CHAIN_ID.POLYGON]: { - blockExplorerUrls: ['https://polygonscan.com'], - chainId: NETWORK_CHAIN_ID.POLYGON, - defaultBlockExplorerUrlIndex: 0, - defaultRpcEndpointIndex: 0, - name: 'Polygon Mainnet', - nativeCurrency: 'MATIC', - rpcEndpoints: [ - { - url: 'https://polygon-rpc.com', - name: 'Polygon', - networkClientId: NETWORK_CHAIN_ID.POLYGON, - type: RpcEndpointType.Custom, - }, - ], - }, -}; - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -jest.mock('../../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ colors: {} })), -})); - -jest.mock('../../../../core/Engine', () => ({ - context: { - PreferencesController: { - setTokenNetworkFilter: jest.fn(), - }, - }, -})); - -jest.mock('@react-navigation/native', () => { - const reactNavigationModule = jest.requireActual('@react-navigation/native'); - return { - ...reactNavigationModule, - useNavigation: () => ({ - navigate: jest.fn(), - goBack: jest.fn(), - }), - }; -}); - -jest.mock('react-native-safe-area-context', () => { - // copied from BottomSheetDialog.test.tsx - const inset = { top: 1, right: 2, bottom: 3, left: 4 }; - const frame = { width: 5, height: 6, x: 7, y: 8 }; - return { - SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), - SafeAreaConsumer: jest - .fn() - .mockImplementation(({ children }) => children(inset)), - useSafeAreaInsets: jest.fn().mockImplementation(() => inset), - useSafeAreaFrame: jest.fn().mockImplementation(() => frame), - }; -}); - -jest.mock( - '../../../hooks/useNetworksByNamespace/useNetworksByNamespace', - () => ({ - useNetworksByNamespace: () => ({ - networks: [ - { - id: 'eip155:1', - name: 'Ethereum', - caipChainId: 'eip155:1', - isSelected: false, - imageSource: - 'https://assets.coingecko.com/coins/images/279/small/ethereum.png?1595348880', - networkTypeOrRpcUrl: 'https://mock-url.com', - }, - ], - }), - NetworkType: { - Popular: 'popular', - Custom: 'custom', - }, - }), -); - -const mockSelectNetwork = jest.fn(); -jest.mock('../../../hooks/useNetworkSelection/useNetworkSelection', () => ({ - useNetworkSelection: () => ({ - selectCustomNetwork: jest.fn(), - selectPopularNetwork: jest.fn(), - selectNetwork: mockSelectNetwork, - }), -})); - -describe('TokenFilterBottomSheet', () => { - beforeEach(() => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; // default chain ID - } else if (selector === selectTokenNetworkFilter) { - return {}; // default to show all networks - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; // default to show all networks - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; // default to show all networks - } - return null; - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders correctly with the default option (All Networks) selected', () => { - const { queryByText } = render(); - - expect(queryByText('Popular networks')).toBeTruthy(); - expect(queryByText('Current network')).toBeTruthy(); - }); - - it('sets filter to All Networks and closes bottom sheet when first option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Popular networks')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith(enableAllNetworksFilter(mockNetworks)); - }); - }); - - it('sets filter to Current Network and closes bottom sheet when second option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x1': true, - }); - }); - }); - - it('displays the correct selection based on tokenNetworkFilter', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; - } else if (selector === selectTokenNetworkFilter) { - return { '0x1': true }; // filter by current network - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; - } - return null; - }); - - const { queryByText } = render(); - - expect(queryByText('Current network')).toBeTruthy(); - }); - - it('updates Network Manager when Popular Networks option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Popular networks')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith(enableAllNetworksFilter(mockNetworks)); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); - }); - }); - - it('updates Network Manager when Current Network option is pressed', async () => { - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x1': true, - }); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x1'); - }); - }); - - it('updates Network Manager with correct chainId for Polygon network', async () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x89'; // Polygon chain ID - } else if (selector === selectTokenNetworkFilter) { - return {}; - } else if (selector === selectNetworkConfigurations) { - return mockNetworks; - } else if (selector === selectAllPopularNetworkConfigurations) { - return mockNetworks; - } - return null; - }); - - const { getByText } = render(); - - fireEvent.press(getByText('Current network')); - - await waitFor(() => { - expect( - Engine.context.PreferencesController.setTokenNetworkFilter, - ).toHaveBeenCalledWith({ - '0x89': true, - }); - expect(mockSelectNetwork).toHaveBeenCalledWith('0x89'); - }); - }); -}); diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx deleted file mode 100644 index f0af6a0c883..00000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { useRef, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { - selectChainId, - selectIsAllNetworks, - selectAllPopularNetworkConfigurations, -} from '../../../../selectors/networkController'; -import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; -import BottomSheet, { - BottomSheetRef, -} from '../../../../component-library/components/BottomSheets/BottomSheet'; -import { useTheme } from '../../../../util/theme'; -import createStyles from '../styles'; -import Engine from '../../../../core/Engine'; -import { View } from 'react-native'; -import Text, { - TextVariant, -} from '../../../../component-library/components/Texts/Text'; -import ListItemSelect from '../../../../component-library/components/List/ListItemSelect'; -import { VerticalAlignment } from '../../../../component-library/components/List/ListItem'; -import { strings } from '../../../../../locales/i18n'; -import { enableAllNetworksFilter } from '../util/enableAllNetworksFilter'; -import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; -import NetworkImageComponent from '../../NetworkImages'; -import { - useNetworksByNamespace, - NetworkType, -} from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; -import { useNetworkSelection } from '../../../hooks/useNetworkSelection/useNetworkSelection'; - -enum FilterOption { - AllNetworks, - CurrentNetwork, -} - -const TokenFilterBottomSheet = () => { - const sheetRef = useRef(null); - const allNetworks = useSelector(selectAllPopularNetworkConfigurations); - const { colors } = useTheme(); - const styles = createStyles(colors); - - const chainId = useSelector(selectChainId); - const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const isAllNetworks = useSelector(selectIsAllNetworks); - const allNetworksEnabled = useMemo( - () => enableAllNetworksFilter(allNetworks), - [allNetworks], - ); - const { networks } = useNetworksByNamespace({ - networkType: NetworkType.Popular, - }); - const { selectNetwork } = useNetworkSelection({ - networks, - }); - - const onFilterControlsBottomSheetPress = (option: FilterOption) => { - const { PreferencesController } = Engine.context; - switch (option) { - case FilterOption.AllNetworks: - PreferencesController.setTokenNetworkFilter(allNetworksEnabled); - sheetRef.current?.onCloseBottomSheet(); - break; - case FilterOption.CurrentNetwork: - PreferencesController.setTokenNetworkFilter({ - [chainId]: true, - }); - sheetRef.current?.onCloseBottomSheet(); - break; - default: - break; - } - selectNetwork(chainId); - }; - - const isCurrentNetwork = Boolean( - tokenNetworkFilter[chainId] && Object.keys(tokenNetworkFilter).length === 1, - ); - - return ( - - - - {strings('wallet.filter_by')} - - - onFilterControlsBottomSheetPress(FilterOption.AllNetworks) - } - isSelected={isAllNetworks} - gap={8} - verticalAlignment={VerticalAlignment.Center} - > - - {strings('wallet.popular_networks')} - - - - - - - onFilterControlsBottomSheetPress(FilterOption.CurrentNetwork) - } - isSelected={isCurrentNetwork} - gap={8} - verticalAlignment={VerticalAlignment.Center} - > - - {strings('wallet.current_network')} - - - - - - - - ); -}; - -export { TokenFilterBottomSheet }; diff --git a/app/components/UI/Tokens/TokensBottomSheet/index.ts b/app/components/UI/Tokens/TokensBottomSheet/index.ts deleted file mode 100644 index c96c7c2199b..00000000000 --- a/app/components/UI/Tokens/TokensBottomSheet/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Routes from '../../../../constants/navigation/Routes'; -import { createNavigationDetails } from '../../../../util/navigation/navUtils'; - -export const createTokensBottomSheetNavDetails = createNavigationDetails( - Routes.MODAL.ROOT_MODAL_FLOW, - Routes.SHEET.TOKEN_SORT, -); - -export const createTokenBottomSheetFilterNavDetails = createNavigationDetails( - Routes.MODAL.ROOT_MODAL_FLOW, - Routes.SHEET.TOKEN_FILTER, -); - -export { TokenSortBottomSheet } from './TokenSortBottomSheet'; -export { TokenFilterBottomSheet } from './TokenFilterBottomSheet'; diff --git a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts index b970bbc0d07..23323b9ec84 100644 --- a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts +++ b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.test.ts @@ -18,21 +18,10 @@ jest.mock('../../../../selectors/tokenRatesController', () => ({ selectTokenMarketData: jest.fn(), })); -jest.mock('../../../../selectors/multichainNetworkController', () => ({ - selectIsEvmNetworkSelected: jest.fn(), -})); - jest.mock('../../../../selectors/multichain/multichain', () => ({ selectMultichainAssetsRates: jest.fn(), })); -jest.mock( - '../../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: jest.fn(), - }), -); - const mockUseSelector = useSelector as jest.MockedFunction; const mockGetNativeTokenAddress = getNativeTokenAddress as jest.MockedFunction< typeof getNativeTokenAddress @@ -90,12 +79,10 @@ describe('useTokenPricePercentageChange', () => { }); describe('Basic token percentage change retrieval', () => { - it('returns percentage change for regular token when multichain accounts state2 disabled and EVM selected', () => { + it('returns percentage change for regular token from EVM data', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -104,12 +91,10 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns percentage change for native token when EVM selected', () => { + it('returns percentage change for native token from EVM data', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockNativeToken), @@ -124,9 +109,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(tokenWithoutAddress), @@ -138,9 +121,7 @@ describe('useTokenPricePercentageChange', () => { it('returns undefined when no asset is provided', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(undefined), @@ -150,28 +131,25 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Multichain accounts state2 enabled scenarios', () => { - it('returns multichain rates when multichain accounts state2 is enabled', () => { + describe('Multichain assets rates scenarios (keyring-snaps)', () => { + it('prioritizes multichain rates when available', () => { mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData (has 5.67) + .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates (has 7.89) const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); + // Returns multichain data (7.89) not EVM data (5.67) expect(result.current).toBe(7.89); }); - it('falls back to EVM price when multichain data is unavailable but state2 enabled', () => { + it('falls back to EVM price when multichain data is unavailable', () => { const emptyMultichainRates = {}; mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(emptyMultichainRates); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -181,11 +159,9 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('falls back to EVM price when multichain data is null but state2 enabled', () => { + it('falls back to EVM price when multichain data is null', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(null); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -195,28 +171,32 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns undefined when both multichain and EVM data are unavailable', () => { + it('falls back to EVM price when multichain data is undefined', () => { mockUseSelector - .mockReturnValueOnce({}) // selectTokenMarketData - empty - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - expect(result.current).toBeUndefined(); + expect(result.current).toBe(5.67); }); - }); - describe('EVM network scenarios', () => { - it('returns EVM percentage change when EVM network selected and state2 disabled', () => { + it('uses EVM fallback when multichain data exists but P1D is missing', () => { + const multichainWithoutP1D = { + '0x1234567890abcdef1234567890abcdef12345678': { + marketData: { + pricePercentChange: { + // P1D missing + }, + }, + }, + }; + mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(multichainWithoutP1D); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -225,34 +205,28 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(5.67); }); - it('returns undefined when EVM selected but no market data available', () => { + it('uses EVM fallback when multichain marketData is missing', () => { + const multichainWithoutMarketData = { + '0x1234567890abcdef1234567890abcdef12345678': { + // marketData missing + }, + }; + mockUseSelector - .mockReturnValueOnce({}) // selectTokenMarketData - empty - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(multichainWithoutMarketData); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - expect(result.current).toBeUndefined(); + expect(result.current).toBe(5.67); }); - it('returns undefined when EVM selected but chain data missing', () => { - const partialMarketData = { - '0x5': { - '0x1234567890abcdef1234567890abcdef12345678': { - pricePercentChange1d: 5.67, - }, - }, - }; - + it('returns undefined when both multichain and EVM data are unavailable', () => { mockUseSelector - .mockReturnValueOnce(partialMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -261,20 +235,20 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); - it('returns undefined when EVM selected but token data missing', () => { - const partialMarketData = { - '0x1': { - '0xother': { - pricePercentChange1d: 5.67, + it('returns undefined when multichain asset data missing for specific token', () => { + const partialMultichainRates = { + 'other-asset-address': { + marketData: { + pricePercentChange: { + P1D: 7.89, + }, }, }, }; mockUseSelector - .mockReturnValueOnce(partialMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce(partialMultichainRates); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -284,29 +258,23 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Non-EVM network scenarios (keyring-snaps)', () => { - it('returns multichain percentage change when EVM not selected and state2 disabled (keyring-snaps)', () => { + describe('EVM market data scenarios', () => { + it('returns EVM percentage change when multichain data not available', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), ); - // This depends on keyring-snaps conditional compilation - // If not available, it might return undefined - expect([7.89, undefined]).toContain(result.current); + expect(result.current).toBe(5.67); }); - it('returns undefined when EVM not selected and no multichain data available', () => { + it('returns undefined when no market data available', () => { mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce({}); // selectMultichainAssetsRates - empty + .mockReturnValueOnce({}) // selectTokenMarketData - empty + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -315,22 +283,38 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); - it('returns undefined when EVM not selected and multichain asset data missing', () => { - const partialMultichainRates = { - 'other-asset-address': { - marketData: { - pricePercentChange: { - P1D: 7.89, - }, + it('returns undefined when chain data missing', () => { + const partialMarketData = { + '0x5': { + '0x1234567890abcdef1234567890abcdef12345678': { + pricePercentChange1d: 5.67, }, }, }; mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(false) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(partialMultichainRates); // selectMultichainAssetsRates + .mockReturnValueOnce(partialMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + const { result } = renderHook(() => + useTokenPricePercentageChange(mockToken), + ); + + expect(result.current).toBeUndefined(); + }); + + it('returns undefined when token data missing for chain', () => { + const partialMarketData = { + '0x1': { + '0xother': { + pricePercentChange1d: 5.67, + }, + }, + }; + + mockUseSelector + .mockReturnValueOnce(partialMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -344,9 +328,7 @@ describe('useTokenPricePercentageChange', () => { it('handles null multichain market data', () => { mockUseSelector .mockReturnValueOnce(null) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -358,9 +340,7 @@ describe('useTokenPricePercentageChange', () => { it('handles undefined multichain market data', () => { mockUseSelector .mockReturnValueOnce(undefined) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -374,9 +354,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(tokenWithoutChainId), @@ -393,16 +371,12 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(nativeTokenWithoutChainId), ); - // When chainId is undefined, the chain lookup fails so getNativeTokenAddress might not be called - // The result should be undefined since there's no valid chainId to look up expect(result.current).toBeUndefined(); }); @@ -417,9 +391,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithZero) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -439,9 +411,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithNegative) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockToken), @@ -451,67 +421,6 @@ describe('useTokenPricePercentageChange', () => { }); }); - describe('Data prioritization and fallbacks', () => { - it('prioritizes multichain data over EVM when state2 enabled', () => { - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData (has 5.67) - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates (has 7.89) - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - // Should return multichain data (7.89) not EVM data (5.67) - expect(result.current).toBe(7.89); - }); - - it('uses EVM fallback when multichain data exists but P1D is missing', () => { - const multichainWithoutP1D = { - '0x1234567890abcdef1234567890abcdef12345678': { - marketData: { - pricePercentChange: { - // P1D missing - }, - }, - }, - }; - - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(multichainWithoutP1D); // selectMultichainAssetsRates - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - expect(result.current).toBe(5.67); // Falls back to EVM data - }); - - it('uses EVM fallback when multichain marketData is missing', () => { - const multichainWithoutMarketData = { - '0x1234567890abcdef1234567890abcdef12345678': { - // marketData missing - }, - }; - - mockUseSelector - .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(true) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(multichainWithoutMarketData); // selectMultichainAssetsRates - - const { result } = renderHook(() => - useTokenPricePercentageChange(mockToken), - ); - - expect(result.current).toBe(5.67); // Falls back to EVM data - }); - }); - describe('Native token address resolution', () => { it('calls getNativeTokenAddress with correct chainId for native tokens', () => { const customChainNativeToken = { @@ -529,9 +438,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(customChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(customChainNativeToken), @@ -555,9 +462,7 @@ describe('useTokenPricePercentageChange', () => { mockUseSelector .mockReturnValueOnce(marketDataWithCustomNative) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates const { result } = renderHook(() => useTokenPricePercentageChange(mockNativeToken), @@ -565,26 +470,32 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBe(12.34); }); + + it('does not call getNativeTokenAddress for non-native tokens', () => { + mockUseSelector + .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + renderHook(() => useTokenPricePercentageChange(mockToken)); + + expect(mockGetNativeTokenAddress).not.toHaveBeenCalled(); + }); }); describe('Selector call verification', () => { - it('calls all required selectors in correct order', () => { + it('calls both selectors', () => { mockUseSelector .mockReturnValueOnce(mockMultiChainMarketData) // selectTokenMarketData - .mockReturnValueOnce(true) // selectIsEvmNetworkSelected - .mockReturnValueOnce(false) // selectMultichainAccountsState2Enabled - .mockReturnValueOnce(mockAllMultichainAssetsRates); // selectMultichainAssetsRates + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates renderHook(() => useTokenPricePercentageChange(mockToken)); - expect(mockUseSelector).toHaveBeenCalledTimes(4); + expect(mockUseSelector).toHaveBeenCalledTimes(2); }); - it('handles all selectors returning null/undefined', () => { + it('handles all selectors returning null', () => { mockUseSelector .mockReturnValueOnce(null) // selectTokenMarketData - .mockReturnValueOnce(null) // selectIsEvmNetworkSelected - .mockReturnValueOnce(null) // selectMultichainAccountsState2Enabled .mockReturnValueOnce(null); // selectMultichainAssetsRates const { result } = renderHook(() => @@ -593,5 +504,17 @@ describe('useTokenPricePercentageChange', () => { expect(result.current).toBeUndefined(); }); + + it('handles all selectors returning undefined', () => { + mockUseSelector + .mockReturnValueOnce(undefined) // selectTokenMarketData + .mockReturnValueOnce(undefined); // selectMultichainAssetsRates + + const { result } = renderHook(() => + useTokenPricePercentageChange(mockToken), + ); + + expect(result.current).toBeUndefined(); + }); }); }); diff --git a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts index 409d40ab74e..996d8ff817b 100644 --- a/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts +++ b/app/components/UI/Tokens/hooks/useTokenPricePercentageChange.ts @@ -1,11 +1,9 @@ import { useSelector } from 'react-redux'; import { TokenI } from '../types'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; -import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { selectMultichainAssetsRates } from '../../../../selectors/multichain/multichain'; import { CaipAssetType, Hex } from '@metamask/utils'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; -import { selectMultichainAccountsState2Enabled } from '../../../../selectors/featureFlagController/multichainAccounts'; /** * Returns the 1 day price percentage change for a given asset. @@ -16,10 +14,7 @@ export const useTokenPricePercentageChange = ( asset?: TokenI, ): number | undefined => { const multiChainMarketData = useSelector(selectTokenMarketData); - const isEvmNetworkSelected = useSelector(selectIsEvmNetworkSelected); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const allMultichainAssetsRates = useSelector(selectMultichainAssetsRates); ///: END:ONLY_INCLUDE_IF(keyring-snaps) @@ -34,17 +29,8 @@ export const useTokenPricePercentageChange = ( ]?.pricePercentChange1d : tokenPercentageChange; - if (isMultichainAccountsState2Enabled) { - return ( - allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData - ?.pricePercentChange?.P1D ?? evmPricePercentChange1d - ); - } - if (isEvmNetworkSelected) { - return evmPricePercentChange1d; - } - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - return allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData - ?.pricePercentChange?.P1D; - ///: END:ONLY_INCLUDE_IF(keyring-snaps) + return ( + allMultichainAssetsRates?.[asset?.address as CaipAssetType]?.marketData + ?.pricePercentChange?.P1D ?? evmPricePercentChange1d + ); }; diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 647c18f7a55..b4270383bbb 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -20,7 +20,7 @@ jest.mock('react-native-device-info', () => ({ const selectedAddress = '0x123'; -jest.mock('./TokensBottomSheet', () => ({ +jest.mock('./TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['BottomSheetScreen', {}]), })); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index a3288332f60..719066ad3f8 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -17,7 +17,7 @@ import { selectNativeNetworkCurrencies, } from '../../../selectors/networkController'; import { getDecimalChainId } from '../../../util/networks'; -import { TokenList } from './TokenList'; +import { TokenList } from './TokenList/TokenList'; import { TokenI } from './types'; import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import { strings } from '../../../../locales/i18n'; @@ -30,12 +30,10 @@ import { import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { Box } from '@metamask/design-system-react-native'; -import { TokenListControlBar } from './TokenListControlBar'; +import { TokenListControlBar } from './TokenListControlBar/TokenListControlBar'; import { selectSelectedInternalAccountId } from '../../../selectors/accountsController'; -import { ScamWarningModal } from './TokenList/ScamWarningModal'; -import TokenListSkeleton from './TokenList/TokenListSkeleton'; -import { selectSortedTokenKeys } from '../../../selectors/tokenList'; -import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts'; +import { ScamWarningModal } from './TokenList/ScamWarningModal/ScamWarningModal'; +import TokenListSkeleton from './TokenList/TokenListSkeleton/TokenListSkeleton'; import { selectSortedAssetsBySelectedAccountGroup } from '../../../selectors/assets/assets-list'; import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts'; import { SolScope } from '@metamask/keyring-api'; @@ -97,21 +95,8 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { const [showScamWarningModal, setShowScamWarningModal] = useState(false); const [hasInitialLoad, setHasInitialLoad] = useState(false); - // BIP44 MAINTENANCE: Once stable, only use selectSortedAssetsBySelectedAccountGroup - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - // Memoize selector computation for better performance - const sortedTokenKeys = useSelector( - useMemo( - () => - isMultichainAccountsState2Enabled - ? selectSortedAssetsBySelectedAccountGroup - : selectSortedTokenKeys, - [isMultichainAccountsState2Enabled], - ), - ); + const sortedTokenKeys = useSelector(selectSortedAssetsBySelectedAccountGroup); // Mark as loaded once we have data (even if empty) useEffect(() => { @@ -245,12 +230,10 @@ const Tokens = memo(({ isFullView = false }: TokensProps) => { )} - {showScamWarningModal && ( - - )} + } title={strings('wallet.remove_token_title')} diff --git a/app/components/UI/Tokens/styles.ts b/app/components/UI/Tokens/styles.ts deleted file mode 100644 index 2632e0082a3..00000000000 --- a/app/components/UI/Tokens/styles.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { StyleSheet } from 'react-native'; -import { fontStyles } from '../../../styles/common'; -import { Colors } from 'app/util/theme/models'; - -const createStyles = (colors: Colors) => - StyleSheet.create({ - bottomSheetTitle: { - alignSelf: 'center', - paddingTop: 16, - paddingBottom: 16, - }, - bottomSheetText: { - width: '100%', - }, - balances: { - flex: 1, - justifyContent: 'center', - marginLeft: 20, - }, - balanceFiat: { - color: colors.text.alternative, - ...fontStyles.normal, - textTransform: 'uppercase', - }, - ethLogo: { - width: 40, - height: 40, - borderRadius: 20, - overflow: 'hidden', - }, - buy: { - alignItems: 'center', - marginVertical: 5, - marginHorizontal: 15, - }, - buyTitle: { - marginVertical: 5, - textAlign: 'center', - }, - buyButton: { - marginVertical: 5, - }, - assetName: { - flexDirection: 'row', - gap: 8, - }, - percentageChange: { - flexDirection: 'row', - alignItems: 'center', - alignContent: 'center', - }, - stakeButton: { - flexDirection: 'row', - }, - dot: { - marginLeft: 2, - marginRight: 2, - }, - portfolioBalance: { - marginHorizontal: 16, - }, - bottomModal: { - justifyContent: 'flex-end', - margin: 0, - }, - box: { - backgroundColor: colors.background.default, - paddingHorizontal: 8, - paddingBottom: 20, - borderWidth: 0, - padding: 0, - }, - boxContent: { - backgroundColor: colors.background.default, - paddingBottom: 21, - paddingTop: 0, - borderWidth: 0, - }, - editNetworkButton: { - width: '100%', - }, - notch: { - width: 40, - height: 4, - borderRadius: 2, - backgroundColor: colors.border.muted, - alignSelf: 'center', - marginTop: 4, - }, - controlIconButton: { - backgroundColor: colors.background.default, - }, - balanceContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - loaderWrapper: { - flexDirection: 'column', - gap: 4, - }, - networkImageContainer: { - position: 'absolute', - right: 0, - }, - badge: { - marginTop: 8, - }, - wrapperSkeleton: { - backgroundColor: colors.background.default, - }, - skeletonItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - }, - skeletonTextContainer: { - flex: 1, - marginLeft: 12, - }, - skeletonValueContainer: { - alignItems: 'flex-end', - }, - }); - -export default createStyles; diff --git a/app/components/UI/Tokens/util/filterAssets.test.ts b/app/components/UI/Tokens/util/filterAssets.test.ts deleted file mode 100644 index 4c23fe54815..00000000000 --- a/app/components/UI/Tokens/util/filterAssets.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { filterAssets, FilterCriteria } from './filterAssets'; - -describe('filterAssets function', () => { - interface MockToken { - name: string; - symbol: string; - chainId: string; - balance: number; - } - - const mockTokens: MockToken[] = [ - { name: 'Token1', symbol: 'T1', chainId: '0x01', balance: 100 }, - { name: 'Token2', symbol: 'T2', chainId: '0x02', balance: 50 }, - { name: 'Token3', symbol: 'T3', chainId: '0x01', balance: 200 }, - { name: 'Token4', symbol: 'T4', chainId: '0x89', balance: 150 }, - ]; - - test('returns all assets if no criteria are provided', () => { - const criteria: FilterCriteria[] = []; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // No filtering occurs - }); - - test('returns all assets if filterCallback is undefined', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, // Valid opts - filterCallback: undefined as unknown as 'inclusive', // Undefined callback - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // No filtering occurs due to missing filterCallback - }); - - test('filters by inclusive chainId', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(3); - expect(filtered.map((token) => token.chainId)).toEqual([ - '0x01', - '0x01', - '0x89', - ]); - }); - - test('filters tokens with balance between 100 and 150 inclusive', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(2); // Token1 and Token4 - expect(filtered.map((token) => token.balance)).toEqual([100, 150]); - }); - - test('filters by inclusive chainId and balance range', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x01': true, '0x89': true }, - filterCallback: 'inclusive', - }, - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(2); // Token1 and Token4 - }); - - test('returns no tokens if no chainId matches', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: { '0x04': true }, - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No matching tokens - }); - - test('returns no tokens if balance is not within range', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 300, max: 400 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No matching tokens - }); - - test('handles empty opts in inclusive callback', () => { - const criteria: FilterCriteria[] = [ - { - key: 'chainId', - opts: {}, // Empty opts - filterCallback: 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No tokens match empty opts - }); - - test('handles invalid range opts', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: undefined, max: undefined } as unknown as { - min: number; - max: number; - }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toHaveLength(0); // No tokens match invalid range - }); - - test('handles missing values in assets gracefully', () => { - const incompleteTokens = [ - { name: 'Token1', symbol: 'T1', chainId: '0x01' }, // Missing balance - ]; - - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'range', - }, - ]; - - const filtered = filterAssets(incompleteTokens, criteria); - - expect(filtered).toHaveLength(0); // Incomplete token doesn't match - }); - - test('ignores unknown filterCallback types', () => { - const criteria: FilterCriteria[] = [ - { - key: 'balance', - opts: { min: 100, max: 150 }, - filterCallback: 'unknown' as unknown as 'inclusive', - }, - ]; - - const filtered = filterAssets(mockTokens, criteria); - - expect(filtered).toEqual(mockTokens); // Unknown callback doesn't filter - }); -}); diff --git a/app/components/UI/Tokens/util/filterAssets.ts b/app/components/UI/Tokens/util/filterAssets.ts deleted file mode 100644 index 7d201831b57..00000000000 --- a/app/components/UI/Tokens/util/filterAssets.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { get } from 'lodash'; - -export interface FilterCriteria { - key: string; - opts: Record; // Use opts for range, inclusion, etc. - filterCallback: FilterCallbackKeys; // Specify the type of filter: 'range', 'inclusive', etc. -} - -export type FilterType = string | number | boolean | Date; -type FilterCallbackKeys = keyof FilterCallbacksT; - -export interface FilterCallbacksT { - inclusive: (value: string, opts: Record) => boolean; - range: (value: number, opts: Record) => boolean; -} - -/** - * A collection of filter callback functions used for various filtering operations. - */ -const filterCallbacks: FilterCallbacksT = { - /** - * Checks if a given value exists as a key in the provided options object - * and returns its corresponding boolean value. - * - * @param value - The key to check in the options object. - * @param opts - A record object containing boolean values for keys. - * @returns `false` if the options object is empty, otherwise returns the boolean value associated with the key. - */ - inclusive: (value: string, opts: Record) => { - if (Object.entries(opts).length === 0) { - return false; - } - return opts[value]; - }, - /** - * Checks if a given numeric value falls within a specified range. - * - * @param value - The number to check. - * @param opts - A record object with `min` and `max` properties defining the range. - * @returns `true` if the value is within the range [opts.min, opts.max], otherwise `false`. - */ - range: (value: number, opts: Record) => - value >= opts.min && value <= opts.max, -}; - -function getNestedValue(obj: T, keyPath: string): FilterType { - return get(obj, keyPath); -} - -/** - * Filters an array of assets based on a set of criteria. - * - * @template T - The type of the assets in the array. - * @param assets - The array of assets to be filtered. - * @param criteria - An array of filter criteria objects. Each criterion contains: - * - `key`: A string representing the key to be accessed within the asset (supports nested keys). - * - `opts`: An object specifying the options for the filter. The structure depends on the `filterCallback` type. - * - `filterCallback`: The filtering method to apply, such as `'inclusive'` or `'range'`. - * @returns A new array of assets that match all the specified criteria. - */ -export function filterAssets(assets: T[], criteria: FilterCriteria[]): T[] { - if (criteria.length === 0) { - return assets; - } - - return assets.filter((asset) => - criteria.every(({ key, opts, filterCallback }) => { - const nestedValue = getNestedValue(asset, key); - - // If there's no callback or options, exit early and don't filter based on this criterion. - if (!filterCallback || !opts) { - return true; - } - - switch (filterCallback) { - case 'inclusive': - return filterCallbacks.inclusive( - nestedValue as string, - opts as Record, - ); - case 'range': - return filterCallbacks.range( - nestedValue as number, - opts as { min: number; max: number }, - ); - default: - return true; - } - }), - ); -} diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx index 96104b9a0cd..59c64c16362 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.test.tsx @@ -38,8 +38,7 @@ jest.mock('../../../hooks/useStyles', () => ({ useStyles: jest.fn(), })); -jest.mock('../../Tokens/TokensBottomSheet', () => ({ - createTokenBottomSheetFilterNavDetails: jest.fn(() => ['TokenFilter', {}]), +jest.mock('../../Tokens/TokenSortBottomSheet/TokenSortBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['TokensBottomSheet', {}]), })); diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx index d2a37b243fe..50199ec2274 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx @@ -19,7 +19,7 @@ import { IconName } from '../../../../component-library/components/Icons/Icon'; import { selectNetworkName } from '../../../../selectors/networkInfos'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { getNetworkImageSource } from '../../../../util/networks'; -import { createTokensBottomSheetNavDetails } from '../../Tokens/TokensBottomSheet'; +import { createTokensBottomSheetNavDetails } from '../../Tokens/TokenSortBottomSheet/TokenSortBottomSheet'; import { createNetworkManagerNavDetails } from '../../NetworkManager'; import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 3b455246baa..77075bdc534 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -111,7 +111,6 @@ import ErrorBoundary from '../ErrorBoundary'; import { Token } from '@metamask/assets-controllers'; import { Hex, KnownCaipNamespace } from '@metamask/utils'; import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController'; -import { PortfolioBalance } from '../../UI/Tokens/TokenList/PortfolioBalance'; import { selectMultichainAccountsState2Enabled } from '../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; import AccountGroupBalance from '../../UI/Assets/components/Balance/AccountGroupBalance'; @@ -1282,11 +1281,7 @@ const Wallet = ({ <> - {isMultichainAccountsState2Enabled ? ( - - ) : ( - - )} + ({ - selectEvmTokens: jest.fn(), - selectEvmTokenFiatBalances: jest.fn(), - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectMultichainTokenListForAccountId: jest.fn(), - ///: END:ONLY_INCLUDE_IF -})); -jest.mock('../store', () => ({ - store: { getState: jest.fn() }, -})); - -// This selector consumes many selectors and is very hard to create exact state -// So instead uses mocks to simulate the internal selector changes -describe('selectSortedTokenKeys', () => { - const mockState = () => ({}) as unknown as RootState; - - const createEvmTokens = (tokenAddrs: string[]) => - tokenAddrs.map( - (address) => - ({ - address, - chainId: '0x1', - isStaked: false, - }) as TokenI, - ); - - const createNonEvmTokens = (tokenAddrs: string[]) => - tokenAddrs.map( - (address, idx) => - ({ - address, - chainId: '0x1337', - isStaked: undefined, - balanceFiat: idx + 10, - }) as unknown as ReturnType< - typeof selectMultichainTokenListForAccountId - >[number], - ); - - const arrangeMocks = () => { - const mockSelectTokenSortConfig = jest - .mocked(selectTokenSortConfig) - .mockReturnValue({ - key: 'tokenFiatAmount', - order: 'dsc', - sortCallback: 'stringNumeric', - }); - - const mockSelectIsEvmNetworkSelected = jest - .mocked(selectIsEvmNetworkSelected) - .mockReturnValue(true); - - const mockEvmTokens = createEvmTokens([ - 'tokenAddr1', - 'tokenAddr2', - 'tokenAddr3', - ]); - const mockSelectEvmTokens = jest - .mocked(selectEvmTokens) - .mockReturnValue(mockEvmTokens); - - const mockEvmTotalFiatBalance = mockEvmTokens.map((_, idx) => idx + 1); - - const mockSelectEvmTokenFiatBalances = jest - .mocked(selectEvmTokenFiatBalances) - .mockReturnValue(mockEvmTotalFiatBalance); - - const mockSelectSelectedInternalAccount = jest - .mocked(selectSelectedInternalAccount) - .mockReturnValue({ id: 'account1' } as InternalAccount); - - const mockNonEvmTokens = createNonEvmTokens([ - 'tokenAddrA', - 'tokenAddrB', - 'tokenAddrC', - ]); - const mockSelectMultichainTokenListForAccountId = jest - .mocked(selectMultichainTokenListForAccountId) - .mockReturnValue(mockNonEvmTokens); - - return { - mockSelectTokenSortConfig, - mockSelectIsEvmNetworkSelected, - mockSelectEvmTokens, - mockSelectEvmTokenFiatBalances, - mockSelectSelectedInternalAccount, - mockSelectMultichainTokenListForAccountId, - }; - }; - - // Setup mocks - beforeEach(() => { - jest.clearAllMocks(); - selectSortedTokenKeys.resetRecomputations(); - }); - - it('returns an array of ordered evm token keys', () => { - const { mockSelectEvmTokens, mockSelectEvmTokenFiatBalances } = - arrangeMocks(); - - // Arrange - setup tokens - mockSelectEvmTokens.mockReturnValue(createEvmTokens(['0x1', '0x2', '0x3'])); - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - - const result = selectSortedTokenKeys(mockState()); - expect(result.map((r) => r.address)).toStrictEqual(['0x3', '0x2', '0x1']); - }); - - it('returns an array of ordered non-evm token keys', () => { - const { - mockSelectIsEvmNetworkSelected, - mockSelectMultichainTokenListForAccountId, - } = arrangeMocks(); - - mockSelectIsEvmNetworkSelected.mockReturnValueOnce(false); - - // Arrange - setup tokens - const nonEvmTokens = createNonEvmTokens(['0x4', '0x5', '0x6']); - nonEvmTokens[0].balanceFiat = '4'; - nonEvmTokens[1].balanceFiat = '5'; - nonEvmTokens[2].balanceFiat = '6'; - mockSelectMultichainTokenListForAccountId.mockReturnValue(nonEvmTokens); - - const result = selectSortedTokenKeys(mockState()); - expect(result.map((r) => r.address)).toStrictEqual(['0x6', '0x5', '0x4']); - }); - - it('returns the exact same result when input values/selectors are the same', () => { - arrangeMocks(); - const result1 = selectSortedTokenKeys(mockState()); - const result2 = selectSortedTokenKeys(mockState()); - expect(result1).toBe(result2); - }); - - it('returns the exact same result when evm fiat fluctuates a tiny bit', () => { - const { mockSelectEvmTokenFiatBalances } = arrangeMocks(); - - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - const result1 = selectSortedTokenKeys(mockState()); - - // fiat values changed, but order remains the same - mockSelectEvmTokenFiatBalances.mockReturnValue([1.1, 2.2, 3.3]); - const result2 = selectSortedTokenKeys(mockState()); - - expect(result1).toBe(result2); - }); - - it('returns a new list or sorted keys when evm fiat changes order', () => { - const { mockSelectEvmTokenFiatBalances } = arrangeMocks(); - - mockSelectEvmTokenFiatBalances.mockReturnValue([1, 2, 3]); - const result1 = selectSortedTokenKeys(mockState()); - - // fiat values changed drastically, order has changed - mockSelectEvmTokenFiatBalances.mockReturnValue([3, 2, 1]); - const result2 = selectSortedTokenKeys(mockState()); - - expect(result1).not.toBe(result2); - }); -}); diff --git a/app/selectors/tokenList.ts b/app/selectors/tokenList.ts deleted file mode 100644 index 6d42bccf710..00000000000 --- a/app/selectors/tokenList.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createSelector } from 'reselect'; -import { selectTokenSortConfig } from './preferencesController'; -import { selectIsEvmNetworkSelected } from './multichainNetworkController'; -///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) -import { selectSelectedInternalAccount } from './accountsController'; -///: END:ONLY_INCLUDE_IF - -import { - selectEvmTokens, - selectEvmTokenFiatBalances, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - selectMultichainTokenListForAccountId, - ///: END:ONLY_INCLUDE_IF -} from './multichain'; -import { RootState } from '../reducers'; -import { TokenI } from '../components/UI/Tokens/types'; -import { sortAssets } from '../components/UI/Tokens/util'; -import { TraceName, endTrace, trace } from '../util/trace'; -import { getTraceTags } from '../util/sentry/tags'; -import { store } from '../store'; -import { createDeepEqualSelector } from './util'; - -const _selectSortedTokenKeys = createSelector( - [ - selectEvmTokens, - selectEvmTokenFiatBalances, - selectIsEvmNetworkSelected, - selectTokenSortConfig, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - (state: RootState) => { - const selectedAccount = selectSelectedInternalAccount(state); - return selectMultichainTokenListForAccountId(state, selectedAccount?.id); - }, - ///: END:ONLY_INCLUDE_IF - ], - ( - evmTokens, - tokenFiatBalances, - isEvmSelected, - tokenSortConfig, - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - nonEvmTokens, - ///: END:ONLY_INCLUDE_IF - ) => { - trace({ - name: TraceName.Tokens, - tags: getTraceTags(store.getState()), - }); - - const tokenListData = isEvmSelected ? evmTokens : nonEvmTokens; - - const tokensWithBalances: TokenI[] = tokenListData.map((token, i) => ({ - ...token, - tokenFiatAmount: isEvmSelected ? tokenFiatBalances[i] : token.balanceFiat, - })); - - const tokensSorted = sortAssets(tokensWithBalances, tokenSortConfig); - - endTrace({ name: TraceName.Tokens }); - - return tokensSorted.map(({ address, chainId, isStaked }) => ({ - address, - chainId, - isStaked, - })); - }, -); - -// Deep equal selector is necessary, because prices can change little bit but order of tokens stays the same. -// So if the previous keys are still valid (deep eq the current list), then we can use the memoized result -export const selectSortedTokenKeys = createDeepEqualSelector( - _selectSortedTokenKeys, - (keys) => keys.filter(({ address, chainId }) => address && chainId), -); From 939e950ad74240dfe6a93f7da526fc498c2b7bd2 Mon Sep 17 00:00:00 2001 From: tommasini <46944231+tommasini@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:01:08 +0000 Subject: [PATCH 03/10] chore: Abstract to Authentication service the store credentials with auth preferences (#23989) ## **Description** This PR refactors the authentication preference management system by centralizing all authentication logic in the `Authentication` service and introducing a new public `updateAuthPreference` method. The changes improve code organization, maintainability, and user experience by providing a unified API for managing authentication preferences (biometric, passcode, password, remember me). **Reason for change:** - Authentication preference logic was scattered across multiple UI components (`SecuritySettings`, `LoginOptionsSettings`, `RememberMeOptionSection`), making it difficult to maintain and test - The `storePassword` method was public but should be protected as it's an internal implementation detail - Storage flag management (`BIOMETRY_CHOICE_DISABLED`, `PASSCODE_DISABLED`) was inconsistently handled across components - The "remember me" feature was not properly prioritized over biometric/passcode authentication - UI components lacked proper loading states and cross-toggle disabling, leading to potential race conditions **Improvement/Solution:** - **Centralized authentication logic**: Created a new public `updateAuthPreference` method in `Authentication` service that handles all authentication preference updates - **Protected internal method**: Made `storePassword` a protected method since it's only used internally by the Authentication service - **Centralized storage flag management**: All storage flag logic (`BIOMETRY_CHOICE_DISABLED`, `PASSCODE_DISABLED`) is now managed exclusively within the protected `storePassword` method, ensuring authentication types are mutually exclusive - **Remember me priority**: Updated `checkAuthenticationMethod` to prioritize `REMEMBER_ME` over biometric/passcode when enabled, ensuring remember me takes precedence - **Removed UI-level authentication code**: Removed all authentication-related code from `SecuritySettings.tsx` (removed `storeCredentials`, `setPassword`, `onSignInWithPasscode`, `onSingInWithBiometrics` functions and `loading` state) - **Direct service calls**: Updated `LoginOptionsSettings` and `RememberMeOptionSection` to directly call `Authentication.updateAuthPreference` instead of receiving callbacks as props - **Improved UX**: Added loading indicators (activity indicators replace toggles during operations) and cross-toggle disabling (biometric toggle disables passcode toggle and vice versa) to prevent concurrent operations - **Updated login flow**: Replaced `Authentication.storePassword` with `Authentication.updateAuthPreference` in the login flow - **Comprehensive test coverage**: Added unit tests for `RememberMeOptionSection` and updated `Authentication.test.ts` to cover remember me priority logic ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Authentication preference management Scenario: User enables biometric authentication Given the user is on the Security Settings screen And biometric authentication is available on the device And the user has a valid password set And passcode authentication is currently disabled When the user toggles biometric authentication ON And enters their password when prompted (if not already stored) Then an activity indicator is shown while processing And the passcode toggle is disabled during the operation And the password is stored with biometric authentication type And biometric authentication is enabled And passcode authentication is disabled (PASSCODE_DISABLED flag set) And the passwordSet Redux action is dispatched Scenario: User enables passcode authentication Given the user is on the Security Settings screen And biometric authentication is available on the device And the user has a valid password set And biometric authentication is currently disabled When the user toggles passcode authentication ON And enters their password when prompted (if not already stored) Then an activity indicator is shown while processing And the biometric toggle is disabled during the operation And the password is stored with passcode authentication type And passcode authentication is enabled And biometric authentication is disabled (BIOMETRY_CHOICE_DISABLED flag set) And the passwordSet Redux action is dispatched Scenario: User enables remember me Given the user is on the Security Settings screen And the user has a valid password set And remember me is currently disabled When the user toggles remember me ON And enters their password when prompted (if not already stored) Then the password is stored with remember me authentication type And remember me is enabled And biometric/passcode authentication is disabled And the passwordSet Redux action is dispatched And remember me takes priority over biometric/passcode on next app unlock Scenario: User disables authentication preference Given the user is on the Security Settings screen And biometric or passcode authentication is currently enabled And the user has a valid password set When the user toggles the authentication preference OFF And enters their password when prompted Then the password is stored with password-only authentication type And both biometric and passcode are disabled And the passwordSet Redux action is dispatched Scenario: User enters invalid password Given the user is on the Security Settings screen And the user is attempting to change authentication preferences When the user enters an invalid password Then an alert is displayed with invalid password message And the error is tracked in analytics And the authentication preference is not changed And the toggle state is reverted Scenario: Remember me takes priority over biometric/passcode Given the user has remember me enabled And biometric authentication is also available and enabled And the user unlocks the app When the app checks authentication method Then remember me authentication is used (not biometric) And the app unlocks without prompting for biometric/passcode ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/7f35ddec-2ee4-45cc-af6f-7916b23acd5c ## **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 - [ ] 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. --- > [!NOTE] > Centralizes auth preference updates in `Authentication.updateAuthPreference`, prioritizes `REMEMBER_ME`, updates settings/login UI with loading/disable states, adds turn-off Remember Me modal, and expands tests with a new storage key. > > - **Authentication Service**: > - Add public `updateAuthPreference(authType, password?)` handling password verification, storage, and Redux side effects; convert biometric-not-enabled to `AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS`. > - Make `storePassword` protected; centralize `BIOMETRY_CHOICE_DISABLED`/`PASSCODE_DISABLED` management and persist `PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME` for restore. > - Prioritize `REMEMBER_ME` in `getType`/auth method resolution; minor login flow cleanup. > - **Settings UI**: > - Replace callback props with direct calls to `Authentication.updateAuthPreference` in `LoginOptionsSettings` and `RememberMeOptionSection`. > - Add loading indicators and cross-toggle disabling; block changes when Remember Me is on; route to `EnterPasswordSimple` when password is required. > - New `TurnOffRememberMeModal`: validates password, restores previous auth type (or PASSWORD), clears stored previous type, locks app, and dismisses safely during loading. > - **Login**: > - Remove legacy credential storing during vault corruption flow; navigate to restore without updating auth storage. > - **Storage**: > - Add `PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME` key. > - **Tests**: > - Extensive new/updated tests for `Authentication`, settings sections, modal, login, import SRP, and reveal private key; snapshot updated. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2423f6bb92bc4d68848f387f04441a894964efb2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: metamaskbot --- .../TurnOffRememberMeModal.test.tsx | 523 +++++++++++ .../TurnOffRememberMeModal.tsx | 107 ++- .../index.test.tsx | 36 + app/components/Views/Login/index.tsx | 32 +- app/components/Views/Login/index2.test.tsx | 68 +- .../RevealPrivateKey.test.tsx | 18 + .../Sections/LoginOptionsSettings.test.tsx | 819 +++++++++++++++++ .../Sections/LoginOptionsSettings.tsx | 259 +++++- .../Sections/RememberMeOptionSection.test.tsx | 832 ++++++++++++++++++ .../Sections/RememberMeOptionSection.tsx | 162 +++- .../SecuritySettings.test.tsx | 22 +- .../SecuritySettings/SecuritySettings.tsx | 134 +-- .../SecuritySettings.test.tsx.snap | 10 +- app/constants/storage.ts | 2 + .../Authentication/Authentication.test.ts | 703 ++++++++++++--- app/core/Authentication/Authentication.ts | 158 +++- 16 files changed, 3514 insertions(+), 371 deletions(-) create mode 100644 app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx create mode 100644 app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx create mode 100644 app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx new file mode 100644 index 00000000000..73ac63fff05 --- /dev/null +++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.test.tsx @@ -0,0 +1,523 @@ +// Mock Text component from component-library FIRST (before any imports that use it) +jest.mock('../../../component-library/components/Texts/Text', () => { + const { Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { + children: React.ReactNode; + variant?: string; + style?: unknown; + }) => {props.children}, + TextVariant: { + HeadingLG: 'HeadingLG', + BodyMD: 'BodyMD', + }, + TextColor: { + Default: 'Default', + Inverse: 'Inverse', + Alternative: 'Alternative', + Muted: 'Muted', + Primary: 'Primary', + PrimaryAlternative: 'PrimaryAlternative', + Success: 'Success', + Error: 'Error', + ErrorAlternative: 'ErrorAlternative', + Warning: 'Warning', + Info: 'Info', + }, + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import TurnOffRememberMeModal from './TurnOffRememberMeModal'; +import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../constants/storage'; + +// Mock Authentication +jest.mock('../../../core', () => ({ + Authentication: { + updateAuthPreference: jest.fn(), + lockApp: jest.fn(), + }, +})); + +// Mock StorageWrapper +jest.mock('../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock doesPasswordMatch +jest.mock('../../../util/password', () => ({ + doesPasswordMatch: jest.fn(), +})); + +// Mock OutlinedTextField +jest.mock('react-native-material-textfield', () => { + const ReactActual = jest.requireActual('react'); + const { TextInput } = jest.requireActual('react-native'); + return { + OutlinedTextField: ReactActual.forwardRef( + ( + { + placeholder, + value, + onChangeText, + editable, + secureTextEntry, + ...props + }: { + placeholder?: string; + value?: string; + onChangeText?: (text: string) => void; + editable?: boolean; + secureTextEntry?: boolean; + [key: string]: unknown; + }, + ref: unknown, + ) => ( + } + placeholder={placeholder} + value={value} + onChangeText={onChangeText} + editable={editable} + secureTextEntry={secureTextEntry} + testID={ + placeholder ? `text-input-${placeholder}` : 'outlined-text-field' + } + {...props} + /> + ), + ), + }; +}); + +// Mock ReusableModal +const mockDismissModal = jest.fn(); +jest.mock('../ReusableModal', () => { + const ReactActual = jest.requireActual('react'); + const { View: RNView } = jest.requireActual('react-native'); + return ReactActual.forwardRef( + ( + { children }: { children: React.ReactNode; isInteractable?: boolean }, + ref: React.Ref<{ dismissModal: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + dismissModal: mockDismissModal, + })); + return {children}; + }, + ); +}); + +// Mock useTheme +jest.mock('../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + primary: { default: '#0000ff' }, + border: { default: '#cccccc' }, + text: { muted: '#999999' }, + }, + themeAppearance: 'light', + }), +})); + +// Mock styles +jest.mock('./styles', () => ({ + createStyles: () => ({ + container: {}, + areYouSure: {}, + textStyle: {}, + input: {}, + }), +})); + +// Mock Box from design-system-react-native +jest.mock('@metamask/design-system-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { + Box: View, + BoxFlexDirection: { + Row: 'row', + }, + BoxAlignItems: { + Center: 'center', + }, + }; +}); + +// Mock strings/i18n +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + +// Mock WarningExistingUserModal +jest.mock('../WarningExistingUserModal', () => { + const { View: RNView, TouchableOpacity: RNTouchableOpacity } = + jest.requireActual('react-native'); + return ({ + children, + cancelText, + cancelButtonDisabled, + onCancelPress, + onRequestClose, + onConfirmPress, + warningModalVisible, + }: { + children: React.ReactNode; + cancelText: string; + cancelButtonDisabled: boolean; + onCancelPress: () => void; + onRequestClose: () => void; + onConfirmPress: () => void; + warningModalVisible: boolean; + }) => { + if (!warningModalVisible) return null; + return ( + + {children} + + {cancelText} + + + + + ); + }; +}); + +describe('TurnOffRememberMeModal', () => { + let mockDoesPasswordMatch: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockLockApp: jest.Mock; + let mockGetItem: jest.Mock; + let mockRemoveItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Get the mocked functions from the modules + const passwordModule = jest.requireMock('../../../util/password'); + mockDoesPasswordMatch = passwordModule.doesPasswordMatch as jest.Mock; + + const coreModule = jest.requireMock('../../../core'); + mockUpdateAuthPreference = coreModule.Authentication + .updateAuthPreference as jest.Mock; + mockLockApp = coreModule.Authentication.lockApp as jest.Mock; + + const storageModule = jest.requireMock('../../../store/storage-wrapper'); + mockGetItem = storageModule.default.getItem as jest.Mock; + mockRemoveItem = storageModule.default.removeItem as jest.Mock; + + // Clear and reset mockDismissModal + mockDismissModal.mockClear(); + + // Set default mock implementations + mockDoesPasswordMatch.mockResolvedValue({ valid: false }); + mockUpdateAuthPreference.mockResolvedValue(undefined); + mockLockApp.mockResolvedValue(undefined); + mockGetItem.mockResolvedValue(null); + mockRemoveItem.mockResolvedValue(undefined); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + it('renders correctly', () => { + const { getByText, getByTestId } = renderWithProvider( + , + { + state: initialState, + }, + ); + + expect(getByTestId('reusable-modal')).toBeTruthy(); + expect(getByTestId('warning-existing-user-modal')).toBeTruthy(); + expect(getByText('turn_off_remember_me.title')).toBeTruthy(); + }); + + it('disables button when password is invalid', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: false }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.changeText(input, 'invalid'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + expect(button.props.disabled).toBe(true); + }); + }); + + it('enables button when password is valid', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + expect(button.props.disabled).toBe(false); + }); + }); + + it('restores previous auth type when disabling remember me', async () => { + mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'ValidPassword123!', + ); + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); + + it('falls back to PASSWORD when no previous auth type is stored', async () => { + mockGetItem.mockResolvedValue(null); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + 'ValidPassword123!', + ); + }); + }); + + it('shows loading indicator during password submission', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect( + queryByTestId('text-input-turn_off_remember_me.placeholder'), + ).toBeNull(); + expect(button.props.disabled).toBe(true); + + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockLockApp).toHaveBeenCalled(); + }); + } + }); + + it('disables input and button during loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId, queryByTestId } = renderWithProvider( + , + { state: initialState }, + ); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect( + queryByTestId('text-input-turn_off_remember_me.placeholder'), + ).toBeNull(); + expect(button.props.disabled).toBe(true); + + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockLockApp).toHaveBeenCalled(); + }); + } + }); + + it('handles error during auth preference update', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValue(error); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); + + it('prevents modal dismissal during loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + expect(mockDismissModal).not.toHaveBeenCalled(); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + expect(mockDismissModal).toHaveBeenCalled(); + }); + } + }); + + it('clears loading state after operation completes', async () => { + mockDoesPasswordMatch.mockResolvedValue({ valid: true }); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const input = getByTestId('text-input-turn_off_remember_me.placeholder'); + fireEvent.changeText(input, 'ValidPassword123!'); + + await waitFor(() => { + expect(mockDoesPasswordMatch).toHaveBeenCalled(); + }); + + const button = getByTestId('warning-modal-cancel-button'); + fireEvent.press(button); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + expect(mockLockApp).toHaveBeenCalled(); + expect(mockDismissModal).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx index b0131fd4402..57973f5e8ab 100644 --- a/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx +++ b/app/components/UI/TurnOffRememberMeModal/TurnOffRememberMeModal.tsx @@ -4,6 +4,7 @@ import { TouchableWithoutFeedback, Keyboard, SafeAreaView, + ActivityIndicator, } from 'react-native'; import Text, { TextVariant, @@ -20,6 +21,15 @@ import { doesPasswordMatch } from '../../../util/password'; import { setAllowLoginWithRememberMe } from '../../../actions/security'; import { useDispatch } from 'react-redux'; import { Authentication } from '../../../core'; +import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import Logger from '../../../util/Logger'; +import StorageWrapper from '../../../store/storage-wrapper'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../constants/storage'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; export const createTurnOffRememberMeModalNavDetails = createNavigationDetails( Routes.MODAL.ROOT_MODAL_FLOW, @@ -35,6 +45,7 @@ const TurnOffRememberMeModal = () => { const [passwordText, setPasswordText] = useState(''); const [disableButton, setDisableButton] = useState(true); + const [isLoading, setIsLoading] = useState(false); const isValidPassword = useCallback( async (text: string): Promise => { @@ -60,24 +71,66 @@ const TurnOffRememberMeModal = () => { const dismissModal = (cb?: () => void): void => modalRef?.current?.dismissModal(cb); - const triggerClose = () => dismissModal(); + const triggerClose = () => { + if (!isLoading) { + dismissModal(); + } + }; const turnOffRememberMeAndLockApp = useCallback(async () => { - dispatch(setAllowLoginWithRememberMe(false)); - Authentication.lockApp(); - }, [dispatch]); + setIsLoading(true); + try { + // Get the previous auth type that was stored before enabling remember me + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Determine which auth method to restore + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + // Use the password entered in the modal to restore auth method + await Authentication.updateAuthPreference( + authTypeToRestore, + passwordText, + ); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem(PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(false)); + Authentication.lockApp(); + // Dismiss modal after successful operation + dismissModal(); + } catch (error) { + // If update fails, still disable remember me and lock app + // The user will need to re-enable their preferred auth method + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + error as Error, + 'Failed to restore auth preference when disabling remember me', + ); + Authentication.lockApp(); + // Dismiss modal even on error + dismissModal(); + } finally { + setIsLoading(false); + } + }, [dispatch, passwordText]); const disableRememberMe = useCallback(async () => { - dismissModal(async () => await turnOffRememberMeAndLockApp()); + // Don't dismiss modal here - let turnOffRememberMeAndLockApp handle it + await turnOffRememberMeAndLockApp(); }, [turnOffRememberMeAndLockApp]); return ( - + { {strings('turn_off_remember_me.description')} - + {isLoading ? ( + + + + ) : ( + + )} diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx index d761c2eeb78..948e1b492ef 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/index.test.tsx @@ -26,6 +26,9 @@ import { endTrace, } from '../../../util/trace'; import type { Span } from '@sentry/core'; +import ReduxService from '../../../core/redux/ReduxService'; +import { RootState } from '../../../reducers'; +import { ReduxStore } from '../../../core/redux/types'; jest.mock('react-native/Libraries/Components/Keyboard/Keyboard', () => ({ dismiss: jest.fn(), @@ -87,12 +90,45 @@ jest.mock('../../hooks/useMetrics', () => { }); describe('ImportFromSecretRecoveryPhrase', () => { + const createMockReduxStore = ( + stateOverrides?: Partial, + ): ReduxStore => { + const defaultState = { + user: { + existingUser: false, + passwordSet: true, + seedphraseBackedUp: false, + }, + security: { + allowLoginWithRememberMe: false, + }, + settings: { + lockTime: -1, + }, + ...(stateOverrides || {}), + } as RootState; + + return { + dispatch: jest.fn(), + getState: jest.fn(() => defaultState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore; + }; + afterEach(() => { jest.clearAllMocks(); + // Restore Redux store mock after clearing mocks + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); beforeEach(() => { jest.clearAllMocks(); + // Mock Redux store for all tests + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); jest diff --git a/app/components/Views/Login/index.tsx b/app/components/Views/Login/index.tsx index 3e3cd506eca..04c234921c2 100644 --- a/app/components/Views/Login/index.tsx +++ b/app/components/Views/Login/index.tsx @@ -268,30 +268,16 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { if (backupResult.vault) { const vaultSeed = await parseVaultValue(password, backupResult.vault); if (vaultSeed) { - // get authType - const authData = await Authentication.componentAuthenticationType( - biometryChoice, - rememberMe, + navigation.replace( + ...createRestoreWalletNavDetailsNested({ + previousScreen: Routes.ONBOARDING.LOGIN, + }), ); - try { - await Authentication.storePassword( - password, - authData.currentAuthType, - ); - navigation.replace( - ...createRestoreWalletNavDetailsNested({ - previousScreen: Routes.ONBOARDING.LOGIN, - }), - ); - setLoading(false); - setError(null); - return; - } catch (e) { - throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} ${e}`); - } - } else { - throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} Invalid Password`); + setLoading(false); + setError(null); + return; } + throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} Invalid Password`); } else if (backupResult.error) { throw new Error(`${LOGIN_VAULT_CORRUPTION_TAG} ${backupResult.error}`); } @@ -308,7 +294,7 @@ const Login: React.FC = ({ saveOnboardingEvent }) => { setError(strings('login.invalid_password')); } - }, [password, biometryChoice, rememberMe, navigation]); + }, [password, navigation]); const navigateToHome = useCallback(async () => { navigation.replace(Routes.ONBOARDING.HOME_NAV); diff --git a/app/components/Views/Login/index2.test.tsx b/app/components/Views/Login/index2.test.tsx index cf88fca19df..79fcdac944a 100644 --- a/app/components/Views/Login/index2.test.tsx +++ b/app/components/Views/Login/index2.test.tsx @@ -130,6 +130,31 @@ jest.mock('../../../multichain-accounts/remote-feature-flag', () => ({ })); describe('Login test suite 2', () => { + const createMockReduxStore = ( + stateOverrides?: RecursivePartial, + ) => { + const defaultState = { + user: { + existingUser: false, + }, + security: { + allowLoginWithRememberMe: false, + }, + settings: { + lockTime: -1, + }, + ...(stateOverrides || {}), + } as RecursivePartial; + + return { + dispatch: jest.fn(), + getState: jest.fn(() => defaultState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore; + }; + beforeAll(() => { jest.useFakeTimers(); }); @@ -138,12 +163,19 @@ describe('Login test suite 2', () => { jest .spyOn(Authentication, 'checkIsSeedlessPasswordOutdated') .mockResolvedValue(false); + + // Mock Redux store for all tests + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.clearAllTimers(); jest.clearAllMocks(); + // Restore Redux store mock after clearing mocks + const mockStore = createMockReduxStore(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore); }); afterAll(() => { @@ -183,7 +215,7 @@ describe('Login test suite 2', () => { }); jest - .spyOn(Authentication, 'storePassword') + .spyOn(Authentication, 'updateAuthPreference') .mockResolvedValueOnce(undefined); const { getByTestId } = renderWithProvider(); @@ -276,21 +308,11 @@ describe('Login test suite 2', () => { .spyOn(Authentication, 'userEntryAuth') .mockRejectedValue(new Error(VAULT_ERROR)); + // Mock getVaultFromBackup to return an error to trigger error handling mockGetVaultFromBackup.mockResolvedValueOnce({ - success: true, - vault: 'mock-vault', + success: false, + error: 'Store password failed', }); - mockParseVaultValue.mockResolvedValueOnce('mock-seed'); - - jest - .spyOn(Authentication, 'componentAuthenticationType') - .mockResolvedValueOnce({ - currentAuthType: AUTHENTICATION_TYPE.PASSCODE, - }); - - jest - .spyOn(Authentication, 'storePassword') - .mockRejectedValueOnce(new Error('Store password failed')); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); @@ -302,7 +324,9 @@ describe('Login test suite 2', () => { fireEvent(passwordInput, 'submitEditing'); }); - expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + await waitFor(() => { + expect(getByTestId(LoginViewSelectors.PASSWORD_ERROR)).toBeTruthy(); + }); }); it('handle vault corruption when vault seed cannot be parsed', async () => { @@ -380,6 +404,12 @@ describe('Login test suite 2', () => { return null; }); const mockState: RecursivePartial = { + user: { + existingUser: false, + }, + security: { + allowLoginWithRememberMe: false, + }, engine: { backgroundState: { SeedlessOnboardingController: { @@ -392,8 +422,14 @@ describe('Login test suite 2', () => { jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ dispatch: jest.fn(), getState: jest.fn(() => mockState), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), } as unknown as ReduxStore); - jest.spyOn(Authentication, 'storePassword').mockResolvedValue(undefined); + jest.spyOn(Authentication, 'userEntryAuth').mockResolvedValue(undefined); + jest + .spyOn(Authentication, 'updateAuthPreference') + .mockResolvedValue(undefined); const { getByTestId } = renderWithProvider(); const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT); diff --git a/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx b/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx index 25097cff25f..27e0abfcb86 100644 --- a/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/RevealPrivateKey/RevealPrivateKey.test.tsx @@ -7,6 +7,8 @@ import { strings } from '../../../../../../locales/i18n'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { SHEET_HEADER_BACK_BUTTON_ID } from '../../../../../component-library/components/Sheet/SheetHeader/SheetHeader.constants'; +import ReduxService from '../../../../../core/redux/ReduxService'; +import { ReduxStore } from '../../../../../core/redux/types'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); @@ -74,6 +76,22 @@ const render = () => { describe('RevealPrivateKey', () => { beforeEach(() => { jest.clearAllMocks(); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: jest.fn(), + getState: () => ({ + user: { existingUser: false }, + security: { allowLoginWithRememberMe: true }, + settings: { lockTime: 1000 }, + }), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('renders correctly with account information', () => { diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx new file mode 100644 index 00000000000..1c3ad8ffc84 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.test.tsx @@ -0,0 +1,819 @@ +// Mock StorageWrapper FIRST (before any imports that use it) +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock Authentication +jest.mock('../../../../../core', () => ({ + Authentication: { + getType: jest.fn(), + updateAuthPreference: jest.fn(), + }, +})); + +// Mock navigation - define navigate function that can be accessed +const mockNavigateFn = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigateFn, + }), + }; +}); + +// Mock useTheme +jest.mock('../../../../../util/theme', () => ({ + useTheme: () => ({ + colors: { + primary: { default: '#0376C9' }, + background: { default: '#FFFFFF' }, + text: { default: '#000000' }, + }, + }), +})); + +// Mock createStyles +jest.mock('../SecuritySettings.styles', () => ({ + __esModule: true, + default: () => ({ + setting: {}, + }), +})); + +// Mock Box and other design system components +jest.mock('@metamask/design-system-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { + Box: ({ + children, + testID, + ...props + }: { + children?: React.ReactNode; + testID?: string; + [key: string]: unknown; + }) => ( + + {children} + + ), + BoxFlexDirection: { Row: 'row', Column: 'column' }, + BoxAlignItems: { Center: 'center' }, + }; +}); + +// Mock SecurityOptionToggle +jest.mock('../../../../UI/SecurityOptionToggle', () => { + const { Switch } = jest.requireActual('react-native'); + return { + SecurityOptionToggle: ({ + testId, + value, + onOptionUpdated, + disabled, + }: { + testId: string; + value: boolean; + onOptionUpdated: (val: boolean) => void; + disabled?: boolean; + }) => ( + + ), + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import LoginOptionsSettings from './LoginOptionsSettings'; +import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; +import { SecurityPrivacyViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/SecurityAndPrivacy/SecurityPrivacyView.selectors'; + +// Mock Device +jest.mock('../../../../../util/device', () => ({ + isAndroid: jest.fn(() => false), + isIos: jest.fn(() => true), +})); + +// Mock Logger +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +// Import the actual constant +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; + +// Mock AuthenticationError as a proper class for instanceof to work +// Must be defined inside the factory because jest.mock is hoisted +jest.mock('../../../../../core/Authentication/AuthenticationError', () => { + class AuthenticationError extends Error { + customErrorMessage: string; + constructor(message: string, code: string) { + super(message); + this.customErrorMessage = code; + this.name = 'AuthenticationError'; + } + } + return { + __esModule: true, + default: AuthenticationError, + }; +}); + +// Get the mocked AuthenticationError class +const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', +).default as new ( + message: string, + code: string, +) => Error & { customErrorMessage: string }; + +describe('LoginOptionsSettings', () => { + let mockGetType: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockGetItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Get the mocked functions from the modules + const coreModule = jest.requireMock('../../../../../core'); + mockGetType = coreModule.Authentication.getType as jest.Mock; + mockUpdateAuthPreference = coreModule.Authentication + .updateAuthPreference as jest.Mock; + + const storageModule = jest.requireMock( + '../../../../../store/storage-wrapper', + ); + mockGetItem = storageModule.default.getItem as jest.Mock; + + // Set default mock implementations + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockResolvedValue(undefined); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: false, + }, + }; + + it('renders correctly', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + await waitFor(() => { + expect( + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ).toBeTruthy(); + }); + }); + + it('enables biometrics when toggle is turned on', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + }); + }); + + it('disables biometrics when toggle is turned off', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, + availableBiometryType: 'FaceID', + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + ); + }); + }); + + it('navigates to password entry when password is required for biometrics', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Simulate password entry + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('clears loading state when user cancels password entry', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Loading should be cleared in finally block even if callback is never called + // This is tested by ensuring the component doesn't get stuck in loading state + await waitFor(() => { + // Component should be interactive again + expect(toggle).toBeTruthy(); + }); + }); + + it('disables biometrics toggle when remember me is enabled', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + expect(toggle.props.disabled).toBe(true); + }); + + it('disables passcode toggle when remember me is enabled', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + expect(toggle.props.disabled).toBe(true); + }); + + it('disables passcode toggle when biometrics is loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const biometricToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(biometricToggle, 'onValueChange', true); + + // Wait for the passcode toggle to be disabled while biometrics is loading + await waitFor(() => { + const passcodeToggle = getByTestId( + SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE, + ); + expect(passcodeToggle.props.disabled).toBe(true); + }); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + // Loading should be cleared + }); + } + }); + + it('disables biometrics toggle when passcode is loading', async () => { + let resolveUpdateAuthPreference: (() => void) | undefined; + const updatePromise = new Promise((resolve) => { + resolveUpdateAuthPreference = resolve; + }); + mockUpdateAuthPreference.mockReturnValue(updatePromise); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + const biometricToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + expect(biometricToggle.props.disabled).toBe(true); + + // Resolve the promise + if (resolveUpdateAuthPreference) { + resolveUpdateAuthPreference(); + await waitFor(() => { + // Loading should be cleared + }); + } + }); + + it('handles error when updating auth preference fails', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + // Toggle should revert to original state on error + await waitFor(() => { + // Component should handle error gracefully + }); + }); + + it('reverts toggle state when password entry callback fails', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + // Simulate password entry that fails + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('navigates to password entry when password is required for passcode', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback for passcode', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + // Initial load: PASSWORD with FaceID available (shows passcode toggle) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // After password entry: PASSCODE + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // Mock getItem for the re-fetch after password entry + mockGetItem + .mockResolvedValueOnce(null) // Initial load + .mockResolvedValueOnce(null); // After password entry + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + // Wait for component to load and passcode toggle to appear + const passcodeToggle = await waitFor( + () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + { timeout: 3000 }, + ); + + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSCODE, + 'test-password', + ); + }); + } + }); + + it('reverts toggle state when passcode password entry callback fails', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSCODE, + 'test-password', + ); + }); + } + }); + + it('re-fetches auth type after successful password entry for biometrics', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // First call: initial load in useEffect + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // Second call: after password entry (re-fetch) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, + availableBiometryType: 'FaceID', + }); + + // Mock getItem for initial load and re-fetch after password entry + mockGetItem + .mockResolvedValueOnce(null) // Initial load + .mockResolvedValueOnce(null); // After password entry (re-fetch) + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.BIOMETRICS_TOGGLE), + ); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor( + () => { + // Should re-fetch auth type after successful update + // Call 1: initial load in useEffect + // Call 2: re-fetch after password entry + expect(mockGetType).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 }, + ); + } + }); + + it('re-fetches auth type after successful password entry for passcode', async () => { + let passwordCallback: ((password: string) => Promise) | undefined; + + // First call: initial load in useEffect + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + // Second call: after password entry (re-fetch) + mockGetType.mockResolvedValueOnce({ + currentAuthType: AUTHENTICATION_TYPE.PASSCODE, + availableBiometryType: 'FaceID', + }); + + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + // Mock getItem for initial load (BIOMETRY_CHOICE_DISABLED and PASSCODE_DISABLED) + // and re-fetch after password entry (PASSCODE_DISABLED) + mockGetItem + .mockResolvedValueOnce(null) // Initial load: BIOMETRY_CHOICE_DISABLED + .mockResolvedValueOnce(null) // Initial load: PASSCODE_DISABLED + .mockResolvedValueOnce(null); // After password entry: PASSCODE_DISABLED + + mockNavigateFn.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + // Wait for component to load and passcode toggle to appear + const passcodeToggle = await waitFor( + () => getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + { timeout: 3000 }, + ); + + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigateFn).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor( + () => { + // Should re-fetch auth type after successful update + // Call 1: initial load in useEffect + // Call 2: re-fetch after password entry + expect(mockGetType).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 }, + ); + } + }); + + it('handles error when updating passcode auth preference fails', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + availableBiometryType: 'FaceID', + }); + + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const passcodeToggle = await waitFor(() => + getByTestId(SecurityPrivacyViewSelectorsIDs.DEVICE_PASSCODE_TOGGLE), + ); + fireEvent(passcodeToggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx index a78fc9d76c4..c74f09e4b00 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/LoginOptionsSettings.tsx @@ -12,25 +12,34 @@ import { PASSCODE_DISABLED, TRUE, } from '../../../../../constants/storage'; -import { View } from 'react-native'; +import { ActivityIndicator } from 'react-native'; import { LOGIN_OPTIONS } from '../SecuritySettings.constants'; import createStyles from '../SecuritySettings.styles'; import { SecurityPrivacyViewSelectorsIDs } from '../../../../../../e2e/selectors/Settings/SecurityAndPrivacy/SecurityPrivacyView.selectors'; +import { + Box, + BoxFlexDirection, + BoxAlignItems, +} from '@metamask/design-system-react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; +import Logger from '../../../../../util/Logger'; +import AuthenticationError from '../../../../../core/Authentication/AuthenticationError'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import { RootState } from '../../../../../reducers'; -interface BiometricOptionSectionProps { - onSignWithBiometricsOptionUpdated: (enabled: boolean) => Promise; - onSignWithPasscodeOptionUpdated: (enabled: boolean) => Promise; -} - -const LoginOptionsSettings = ({ - onSignWithBiometricsOptionUpdated, - onSignWithPasscodeOptionUpdated, -}: BiometricOptionSectionProps) => { +const LoginOptionsSettings = () => { + const navigation = useNavigation(); + const allowLoginWithRememberMe = useSelector( + (state: RootState) => state.security?.allowLoginWithRememberMe, + ); const [biometryType, setBiometryType] = useState< BIOMETRY_TYPE | AUTHENTICATION_TYPE.BIOMETRIC | undefined >(undefined); const [biometryChoice, setBiometryChoice] = useState(false); const [passcodeChoice, setPasscodeChoice] = useState(false); + const [isBiometricLoading, setIsBiometricLoading] = useState(false); + const [isPasscodeLoading, setIsPasscodeLoading] = useState(false); const { colors } = useTheme(); const styles = createStyles(colors); @@ -67,46 +76,220 @@ const LoginOptionsSettings = ({ const onBiometricsOptionUpdated = useCallback( async (enabled: boolean) => { - await onSignWithBiometricsOptionUpdated(enabled); - setBiometryChoice(enabled); + // Prevent toggling biometrics when remember me is enabled + if (allowLoginWithRememberMe) { + return; + } + + setIsBiometricLoading(true); + try { + const authType = enabled + ? AUTHENTICATION_TYPE.BIOMETRIC + : AUTHENTICATION_TYPE.PASSWORD; + + // Enabling biometrics is handled by the catch condition "isPasswordRequiredError" + await Authentication.updateAuthPreference(authType); + + // Only update UI if operation completed successfully + setBiometryChoice(enabled); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const authType = enabled + ? AUTHENTICATION_TYPE.BIOMETRIC + : AUTHENTICATION_TYPE.PASSWORD; + + navigation.navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + // Set loading back to true when callback is invoked + setIsBiometricLoading(true); + try { + await Authentication.updateAuthPreference( + authType, + enteredPassword, + ); + + // Update UI state after successful password entry and update + setBiometryChoice(enabled); + + // Re-fetch to ensure UI matches actual state + const currentAuthType = await Authentication.getType(); + const previouslyDisabled = await StorageWrapper.getItem( + BIOMETRY_CHOICE_DISABLED, + ); + setBiometryChoice( + currentAuthType.currentAuthType === + AUTHENTICATION_TYPE.BIOMETRIC && + !(previouslyDisabled && previouslyDisabled === TRUE), + ); + } catch (updateError) { + // On error, revert UI state + setBiometryChoice(!enabled); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } finally { + // Clear loading after callback completes + setIsBiometricLoading(false); + } + }, + }); + // Don't update UI state here - wait for callback + return; + } + // Other error - revert toggle state + Logger.error( + error as Error, + 'Failed to update auth preference after password entry', + ); + setBiometryChoice(!enabled); + } finally { + setIsBiometricLoading(false); + } }, - [onSignWithBiometricsOptionUpdated], + [navigation, allowLoginWithRememberMe], ); const onPasscodeOptionUpdated = useCallback( async (enabled: boolean) => { - await onSignWithPasscodeOptionUpdated(enabled); - setPasscodeChoice(enabled); + // Prevent toggling passcode when remember me is enabled + if (allowLoginWithRememberMe) { + return; + } + + setIsPasscodeLoading(true); + try { + const authType = enabled + ? AUTHENTICATION_TYPE.PASSCODE + : AUTHENTICATION_TYPE.PASSWORD; + + // Enabling passcode is handled by the catch condition "isPasswordRequiredError" + await Authentication.updateAuthPreference(authType); + + // Only update UI if operation completed successfully + setPasscodeChoice(enabled); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const authType = enabled + ? AUTHENTICATION_TYPE.PASSCODE + : AUTHENTICATION_TYPE.PASSWORD; + + navigation.navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + // Set loading back to true when callback is invoked + setIsPasscodeLoading(true); + try { + await Authentication.updateAuthPreference( + authType, + enteredPassword, + ); + + // Update UI state after successful password entry and update + setPasscodeChoice(enabled); + + // Re-fetch to ensure UI matches actual state + const currentAuthType = await Authentication.getType(); + const passcodePreviouslyDisabled = + await StorageWrapper.getItem(PASSCODE_DISABLED); + setPasscodeChoice( + currentAuthType.currentAuthType === + AUTHENTICATION_TYPE.PASSCODE && + !( + passcodePreviouslyDisabled && + passcodePreviouslyDisabled === TRUE + ), + ); + } catch (updateError) { + // On error, revert UI state + setPasscodeChoice(!enabled); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } finally { + // Clear loading after callback completes + setIsPasscodeLoading(false); + } + }, + }); + // Don't update UI state here - wait for callback + return; + } + // Other error - revert toggle state + Logger.error( + error as Error, + 'Failed to update auth preference after password entry', + ); + setPasscodeChoice(!enabled); + } finally { + setIsPasscodeLoading(false); + } }, - [onSignWithPasscodeOptionUpdated], + [navigation, allowLoginWithRememberMe], ); return ( - + {biometryType ? ( - - - + + {isBiometricLoading ? ( + + + + ) : ( + + )} + ) : null} {biometryType && !biometryChoice ? ( - - - + + {isPasscodeLoading ? ( + + + + ) : ( + + )} + ) : null} - + ); }; diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx new file mode 100644 index 00000000000..0300ab024b7 --- /dev/null +++ b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.test.tsx @@ -0,0 +1,832 @@ +jest.mock('../../../../../store/storage-wrapper', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock locales/i18n to prevent it from using StorageWrapper during import +jest.mock('../../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +// Mock Authentication +jest.mock('../../../../../core', () => { + const mockGetTypeFn = jest.fn(); + const mockUpdateAuthPreferenceFn = jest.fn(); + return { + Authentication: { + getType: mockGetTypeFn, + updateAuthPreference: mockUpdateAuthPreferenceFn, + }, + __mockGetType: mockGetTypeFn, + __mockUpdateAuthPreference: mockUpdateAuthPreferenceFn, + }; +}); + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import RememberMeOptionSection from './RememberMeOptionSection'; +import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; +import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage'; +import Logger from '../../../../../util/Logger'; + +// Mock navigation +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + }), + }; +}); + +// Mock TurnOffRememberMeModal +jest.mock( + '../../../../UI/TurnOffRememberMeModal/TurnOffRememberMeModal', + () => ({ + createTurnOffRememberMeModalNavDetails: jest.fn(() => [ + 'TurnOffRememberMe', + {}, + ]), + }), +); + +// Mock AuthenticationError +jest.mock('../../../../../core/Authentication/AuthenticationError', () => { + class AuthenticationError extends Error { + customErrorMessage: string; + + constructor(message: string, code: string) { + super(message); + this.customErrorMessage = code; + this.name = 'AuthenticationError'; + } + } + + return { + __esModule: true, + default: AuthenticationError, + }; +}); + +// Mock Logger +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +describe('RememberMeOptionSection', () => { + let mockGetType: jest.Mock; + let mockUpdateAuthPreference: jest.Mock; + let mockGetItem: jest.Mock; + let mockRemoveItem: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + const AuthenticationMock = jest.requireMock('../../../../../core'); + mockGetType = AuthenticationMock.__mockGetType; + mockUpdateAuthPreference = AuthenticationMock.__mockUpdateAuthPreference; + + // Get mocked StorageWrapper functions + const storageModule = jest.requireMock( + '../../../../../store/storage-wrapper', + ); + mockGetItem = storageModule.default.getItem as jest.Mock; + mockRemoveItem = storageModule.default.removeItem as jest.Mock; + + // Reset mocks to default behavior + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockUpdateAuthPreference.mockResolvedValue(undefined); + mockGetItem.mockResolvedValue(null); + mockRemoveItem.mockResolvedValue(undefined); + mockNavigate.mockClear(); + }); + + const initialState = { + security: { + allowLoginWithRememberMe: false, + }, + }; + + it('renders correctly', () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + expect(getByTestId(TURN_ON_REMEMBER_ME)).toBeTruthy(); + }); + + it('calls getType when attempting to disable remember me', async () => { + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + }); + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + }); + + it('calls updateAuthPreference when enabling remember me', async () => { + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + }); + }); + + it('does not call updateAuthPreference when disabling remember me', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + }); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + // Should navigate to turn off modal, not call updateAuthPreference + expect(mockNavigate).toHaveBeenCalled(); + }); + + expect(mockUpdateAuthPreference).not.toHaveBeenCalled(); + }); + + it('reverts flag if updateAuthPreference fails when enabling', async () => { + mockUpdateAuthPreference.mockRejectedValueOnce(new Error('Update failed')); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + + // The component should handle the error and revert the flag + // We verify updateAuthPreference was called and failed + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + }); + + it('displays correct toggle value based on Redux state', () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + expect(toggle.props.value).toBe(true); + }); + + it('navigates to password entry when password is required for enabling remember me', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('updates auth preference when password is provided via callback when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + 'test-password', + ); + }); + } + }); + + it('reverts flag when password entry callback fails when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(new Error('Update failed')); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.REMEMBER_ME, + 'test-password', + ); + }); + } + }); + + it('calls Logger.error when updateAuthPreference fails when enabling', async () => { + const error = new Error('Update failed'); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + error, + 'Failed to update auth preference for remember me', + ); + }); + }); + + it('calls Logger.error when password entry callback fails when enabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const updateError = new Error('Update failed'); + let passwordCallback: ((password: string) => Promise) | undefined; + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(updateError); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', true); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + updateError, + 'Failed to update auth preference after password entry', + ); + }); + } + }); + + it('successfully disables remember me and restores password auth type', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + // Wait for getType to be called (from onValueChanged) + await waitFor( + () => { + expect(mockGetType).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + + // Wait for getItem to be called (from toggleRememberMe) + await waitFor( + () => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }, + { timeout: 3000 }, + ); + + // Wait for updateAuthPreference to be called + await waitFor( + () => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + ); + }, + { timeout: 3000 }, + ); + + // Wait for removeItem to be called + await waitFor( + () => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }, + { timeout: 3000 }, + ); + }); + + it('successfully disables remember me and restores stored previous auth type', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(AUTHENTICATION_TYPE.BIOMETRIC); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockGetItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + }); + + await waitFor(() => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + }); + + it('navigates to password entry when password is required for disabling remember me', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('EnterPasswordSimple', { + onPasswordSet: expect.any(Function), + }); + }); + }); + + it('restores auth preference when password is provided via callback when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.PASSWORD, + 'test-password', + ); + }); + + await waitFor(() => { + expect(mockRemoveItem).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + }); + } + }); + + it('restores stored previous auth type when password is provided via callback when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem + .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC) + .mockResolvedValueOnce(AUTHENTICATION_TYPE.BIOMETRIC); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockResolvedValueOnce(undefined); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalledWith( + AUTHENTICATION_TYPE.BIOMETRIC, + 'test-password', + ); + }); + } + }); + + it('reverts flag when password entry callback fails when disabling', async () => { + const MockedAuthenticationError = jest.requireMock( + '../../../../../core/Authentication/AuthenticationError', + ).default; + + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const updateError = new Error('Update failed'); + let passwordCallback: ((password: string) => Promise) | undefined; + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference + .mockRejectedValueOnce( + new MockedAuthenticationError( + 'Password required', + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ), + ) + .mockRejectedValueOnce(updateError); + + mockNavigate.mockImplementation( + ( + screen: string, + params?: { onPasswordSet?: (password: string) => Promise }, + ) => { + if (screen === 'EnterPasswordSimple' && params?.onPasswordSet) { + passwordCallback = params.onPasswordSet; + } + }, + ); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalled(); + }); + + if (passwordCallback) { + await passwordCallback('test-password'); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + updateError, + 'Failed to restore auth preference after password entry', + ); + }); + } + }); + + it('calls Logger.error when updateAuthPreference fails when disabling', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + const error = new Error('Restore failed'); + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + mockUpdateAuthPreference.mockRejectedValueOnce(error); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(Logger.error).toHaveBeenCalledWith( + error, + 'Failed to restore auth preference when disabling remember me', + ); + }); + }); + + it('proceeds with toggle when getType returns non-REMEMBER_ME when trying to disable', async () => { + const stateWithRememberMe = { + security: { + allowLoginWithRememberMe: true, + }, + }; + + mockGetType.mockResolvedValue({ + currentAuthType: AUTHENTICATION_TYPE.PASSWORD, + }); + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: stateWithRememberMe, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockGetType).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); + + it('proceeds with toggle when allowLoginWithRememberMe is false but user tries to disable', async () => { + mockGetItem.mockResolvedValue(null); + + const { getByTestId } = renderWithProvider(, { + state: initialState, + }); + + const toggle = getByTestId(TURN_ON_REMEMBER_ME); + fireEvent(toggle, 'onValueChange', false); + + await waitFor(() => { + expect(mockUpdateAuthPreference).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx index 8eb7bc42d8c..75d0a59b9bd 100644 --- a/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx +++ b/app/components/Views/Settings/SecuritySettings/Sections/RememberMeOptionSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { SecurityOptionToggle } from '../../../../UI/SecurityOptionToggle'; import { strings } from '../../../../../../locales/i18n'; import { useSelector, useDispatch } from 'react-redux'; @@ -9,42 +9,162 @@ import { createTurnOffRememberMeModalNavDetails } from '../../../..//UI/TurnOffR import { Authentication } from '../../../../../core'; import AUTHENTICATION_TYPE from '../../../../../constants/userProperties'; import { TURN_ON_REMEMBER_ME } from '../SecuritySettings.constants'; +import Logger from '../../../../../util/Logger'; +import AuthenticationError from '../../../../../core/Authentication/AuthenticationError'; +import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../../../constants/error'; +import StorageWrapper from '../../../../../store/storage-wrapper'; +import { PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME } from '../../../../../constants/storage'; const RememberMeOptionSection = () => { const { navigate } = useNavigation(); const allowLoginWithRememberMe = useSelector( // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - (state: any) => state.security.allowLoginWithRememberMe, + (state: any) => state.security?.allowLoginWithRememberMe, ); - const [isUsingRememberMe, setIsUsingRememberMe] = useState(false); - useEffect(() => { - const checkIfAlreadyUsingRememberMe = async () => { - const authType = await Authentication.getType(); - setIsUsingRememberMe( - authType.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME, - ); - }; - checkIfAlreadyUsingRememberMe(); - }, []); - const dispatch = useDispatch(); const toggleRememberMe = useCallback( - (value: boolean) => { - dispatch(setAllowLoginWithRememberMe(value)); + async (value: boolean) => { + // If enabling remember me, update the password storage type first + if (value) { + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.REMEMBER_ME, + enteredPassword, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (updateError) { + // If update fails, revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + updateError as Error, + 'Failed to update auth preference after password entry', + ); + } + }, + }); + return; + } + // Other error - revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(false)); + Logger.error( + error as Error, + 'Failed to update auth preference for remember me', + ); + } + } else { + // Disabling remember me - restore previous authentication method + try { + // Get the previous auth type that was stored before enabling remember me + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Determine which auth method to restore + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + await Authentication.updateAuthPreference(authTypeToRestore); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (error) { + // Check if error is "password required" - navigate to password entry + const isPasswordRequiredError = + error instanceof AuthenticationError && + error.customErrorMessage === + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS; + + if (isPasswordRequiredError) { + // Navigate to password entry + const previousAuthType = await StorageWrapper.getItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + + // Use stored previous auth type if available, otherwise fall back to password + const authTypeToRestore = previousAuthType + ? (previousAuthType as AUTHENTICATION_TYPE) + : AUTHENTICATION_TYPE.PASSWORD; + + navigate('EnterPasswordSimple', { + onPasswordSet: async (enteredPassword: string) => { + try { + await Authentication.updateAuthPreference( + authTypeToRestore, + enteredPassword, + ); + // Clear the stored previous auth type after successful restoration + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + // Only set Redux state after operation completes successfully + dispatch(setAllowLoginWithRememberMe(value)); + } catch (updateError) { + // If update fails, revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(true)); + Logger.error( + updateError as Error, + 'Failed to restore auth preference after password entry', + ); + } + }, + }); + // Don't set Redux state here - wait for callback to complete + return; + } + // Other error - revert the flag to ensure UI matches actual state + dispatch(setAllowLoginWithRememberMe(true)); + Logger.error( + error as Error, + 'Failed to restore auth preference when disabling remember me', + ); + } + } }, - [dispatch], + [dispatch, navigate], ); const onValueChanged = useCallback( - (enabled: boolean) => { - isUsingRememberMe - ? navigate(...createTurnOffRememberMeModalNavDetails()) - : toggleRememberMe(enabled); + async (enabled: boolean) => { + // Check if remember me is currently active by checking the actual auth type + // This ensures we always have the current state + if (!enabled && allowLoginWithRememberMe) { + // User is trying to disable remember me - check if it's actually active + const authType = await Authentication.getType(); + if (authType.currentAuthType === AUTHENTICATION_TYPE.REMEMBER_ME) { + navigate(...createTurnOffRememberMeModalNavDetails()); + return; + } + } + // Otherwise, proceed with normal toggle + await toggleRememberMe(enabled); }, - [isUsingRememberMe, navigate, toggleRememberMe], + [allowLoginWithRememberMe, navigate, toggleRememberMe], ); return ( diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx index 5834d8f0a31..fff14451ca8 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.test.tsx @@ -20,6 +20,8 @@ import { SecurityPrivacyViewSelectorsIDs } from '../../../../../e2e/selectors/Se import SECURITY_ALERTS_TOGGLE_TEST_ID from './constants'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; import { strings } from '../../../../../locales/i18n'; +import ReduxService from '../../../../core/redux/ReduxService'; +import { ReduxStore } from '../../../../core/redux/types'; const initialState = { privacy: { approvedHosts: {} }, @@ -85,14 +87,30 @@ describe('SecuritySettings', () => { mockUseParamsValues = { scrollToDetectNFTs: undefined, }; + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: jest.fn(), + getState: () => ({ + user: { existingUser: false }, + security: { allowLoginWithRememberMe: true }, + settings: { lockTime: 1000 }, + }), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + [Symbol.observable]: jest.fn(), + } as unknown as ReduxStore); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); - it('should render correctly', () => { + it('renders correctly', () => { const wrapper = renderWithProvider(, { state: initialState, }); expect(wrapper.toJSON()).toMatchSnapshot(); }); - it('should render all sections', () => { + it('renders all sections', () => { const { getByText, getByTestId } = renderWithProvider( , { diff --git a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx index 6179152a25f..dbdbc373493 100644 --- a/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx +++ b/app/components/Views/Settings/SecuritySettings/SecuritySettings.tsx @@ -1,37 +1,18 @@ /* eslint-disable react/prop-types */ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - Alert, - Switch, - ScrollView, - View, - ActivityIndicator, - Keyboard, - Linking, -} from 'react-native'; +import { Switch, ScrollView, View, Keyboard, Linking } from 'react-native'; import StorageWrapper from '../../../../store/storage-wrapper'; import { useDispatch, useSelector } from 'react-redux'; import { MAINNET } from '../../../../constants/network'; import ActionModal from '../../../UI/ActionModal'; import { clearHistory } from '../../../../actions/browser'; -import Logger from '../../../../util/Logger'; import { getNavigationOptionsTitle } from '../../../UI/Navbar'; -import { setLockTime } from '../../../../actions/settings'; import { SIMULATION_DETALS_ARTICLE_URL } from '../../../../constants/urls'; import { strings } from '../../../../../locales/i18n'; -import { passwordSet, setExistingUser } from '../../../../actions/user'; import Engine from '../../../../core/Engine'; -import AppConstants from '../../../../core/AppConstants'; -import { - TRUE, - PASSCODE_DISABLED, - BIOMETRY_CHOICE_DISABLED, - SEED_PHRASE_HINTS, -} from '../../../../constants/storage'; +import { SEED_PHRASE_HINTS } from '../../../../constants/storage'; import HintModal from '../../../UI/HintModal'; import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; -import { Authentication } from '../../../../core'; -import AUTHENTICATION_TYPE from '../../../../constants/userProperties'; import { useTheme } from '../../../../util/theme'; import { ClearCookiesSection, @@ -55,9 +36,7 @@ import { HeadingProps, SecuritySettingsParams } from './SecuritySettings.types'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { useParams } from '../../../../util/navigation/navUtils'; import { - BIOMETRY_CHOICE_STRING, CLEAR_BROWSER_HISTORY_SECTION, - PASSCODE_CHOICE_STRING, SDK_SECTION, } from './SecuritySettings.constants'; import Text, { @@ -69,7 +48,6 @@ import Button, { ButtonSize, ButtonWidthTypes, } from '../../../../component-library/components/Buttons/Button'; -import trackErrorAsAnalytics from '../../../../util/metrics/TrackError/trackErrorAsAnalytics'; import BasicFunctionalityComponent from '../../../UI/BasicFunctionality/BasicFunctionality'; import Routes from '../../../../constants/navigation/Routes'; import MetaMetricsAndDataCollectionSection from './Sections/MetaMetricsAndDataCollectionSection/MetaMetricsAndDataCollectionSection'; @@ -105,7 +83,6 @@ const Settings: React.FC = () => { const navigation = useNavigation(); const params = useParams(); const dispatch = useDispatch(); - const [loading, setLoading] = useState(false); const [browserHistoryModalVisible, setBrowserHistoryModalVisible] = useState(false); const [analyticsEnabled, setAnalyticsEnabled] = useState(false); @@ -127,7 +104,6 @@ const Settings: React.FC = () => { (state: RootState) => state.browser.history, ); - const lockTime = useSelector((state: RootState) => state.settings.lockTime); const useTransactionSimulations = useSelector( selectUseTransactionSimulations, ); @@ -253,99 +229,6 @@ const Settings: React.FC = () => { } }; - const storeCredentials = async ( - password: string, - enabled: boolean, - authChoice: string, - ) => { - try { - await Authentication.resetPassword(); - - await Engine.context.KeyringController.exportSeedPhrase(password); - - // Mark user as existing when they set up authentication - dispatch(setExistingUser(true)); - - if (!enabled) { - setLoading(false); - if (authChoice === PASSCODE_CHOICE_STRING) { - await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - } else if (authChoice === BIOMETRY_CHOICE_STRING) { - await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); - await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - } - - return; - } - - try { - let authType; - if (authChoice === BIOMETRY_CHOICE_STRING) { - authType = AUTHENTICATION_TYPE.BIOMETRIC; - } else if (authChoice === PASSCODE_CHOICE_STRING) { - authType = AUTHENTICATION_TYPE.PASSCODE; - } else { - authType = AUTHENTICATION_TYPE.PASSWORD; - } - await Authentication.storePassword(password, authType); - } catch (error) { - Logger.error(error as unknown as Error, {}); - } - - dispatch(passwordSet()); - - if (lockTime === -1) { - dispatch(setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT)); - } - setLoading(false); - } catch (e) { - const errorWithMessage = e as { message: string }; - if (errorWithMessage.message === 'Invalid password') { - Alert.alert( - strings('app_settings.invalid_password'), - strings('app_settings.invalid_password_message'), - ); - trackErrorAsAnalytics( - 'SecuritySettings: Invalid password', - errorWithMessage?.message, - '', - ); - } else { - Logger.error(e as unknown as Error, 'SecuritySettings:biometrics'); - } - setLoading(false); - } - }; - - const setPassword = async (enabled: boolean, passwordType: string) => { - setLoading(true); - let credentials; - try { - credentials = await Authentication.getPassword(); - } catch (error) { - Logger.error(error as unknown as Error, {}); - } - - if (credentials && credentials.password !== '') { - storeCredentials(credentials.password, enabled, passwordType); - } else { - setLoading(false); - navigation.navigate('EnterPasswordSimple', { - onPasswordSet: (password: string) => { - storeCredentials(password, enabled, passwordType); - }, - }); - } - }; - - const onSignInWithPasscode = async (enabled: boolean) => { - await setPassword(enabled, PASSCODE_CHOICE_STRING); - }; - - const onSingInWithBiometrics = async (enabled: boolean) => { - await setPassword(enabled, BIOMETRY_CHOICE_STRING); - }; - const goToSDKSessionManager = () => { navigation.navigate('SDKSessionsManager'); }; @@ -509,14 +392,6 @@ const Settings: React.FC = () => { }); }; - if (loading) { - return ( - - - - ); - } - const modalLoading = disableNotificationsLoading; const modalError = disableNotificationsError; @@ -535,10 +410,7 @@ const Settings: React.FC = () => { /> - + diff --git a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap index a88e6271bcc..ff233c699a0 100644 --- a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap +++ b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SecuritySettings should render correctly 1`] = ` +exports[`SecuritySettings renders correctly 1`] = ` ({ log: jest.fn(), })); +jest.mock('react-native', () => ({ + Alert: { + alert: jest.fn(), + }, +})); + +jest.mock('../../../locales/i18n', () => ({ + strings: jest.fn((key: string) => key), +})); + +jest.mock('../../util/metrics/TrackError/trackErrorAsAnalytics', () => + jest.fn(), +); + const mockTrace = jest.fn(); const mockEndTrace = jest.fn(); const mockGetTraceTags = jest.fn(); @@ -266,14 +290,13 @@ describe('Authentication', () => { jest.runAllTimers(); }); - it('should return a type password', async () => { + it('returns PASSWORD type when biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); - // Mock Redux store to return existingUser: false jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ getState: () => ({ user: { existingUser: false }, @@ -282,11 +305,12 @@ describe('Authentication', () => { } as unknown as ReduxStore); const result = await Authentication.getType(); + expect(result.availableBiometryType).toEqual('FaceID'); expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type biometric', async () => { + it('returns BIOMETRIC type when biometric is available and not disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); @@ -304,7 +328,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); }); - it('should return a type passcode', async () => { + it('returns PASSCODE type when biometric is disabled but passcode is available', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -323,7 +347,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSCODE); }); - it('should return a type password with biometric & pincode disabled', async () => { + it('returns PASSWORD type when both biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -343,7 +367,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type AUTHENTICATION_TYPE.REMEMBER_ME if the user exists and there are no available biometrics options and the password exist in the keychain', async () => { + it('returns REMEMBER_ME type when user exists, no biometrics available, and password exists in keychain', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); const mockCredentials = { username: 'test', password: 'test' }; SecureKeychain.getGenericPassword = jest @@ -363,7 +387,92 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); }); - it('should return a type AUTHENTICATION_TYPE.PASSWORD if the user exists and there are no available biometrics options but the password does not exist in the keychain', async () => { + it('prioritizes REMEMBER_ME over BIOMETRIC when remember me is enabled', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true and remember me enabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); + expect(result.availableBiometryType).toEqual('FaceID'); + }); + + it('prioritizes REMEMBER_ME over PASSCODE when remember me is enabled', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true and remember me enabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); + expect(result.availableBiometryType).toEqual('Fingerprint'); + }); + + it('returns BIOMETRIC when remember me is disabled even if password exists', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + const mockCredentials = { username: 'test', password: 'test' }; + SecureKeychain.getGenericPassword = jest + .fn() + .mockReturnValue(mockCredentials); + + // Mock Redux store to return existingUser: true but remember me disabled + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: false }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); + expect(result.availableBiometryType).toEqual('FaceID'); + }); + + it('returns BIOMETRIC when remember me is enabled but password does not exist in keychain', async () => { + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ + user: { existingUser: true }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + const result = await Authentication.getType(); + + expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); + }); + + it('returns PASSWORD type when user exists, no biometrics available, and password does not exist in keychain', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); @@ -380,7 +489,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a type AUTHENTICATION_TYPE.PASSWORD if the user does not exist and there are no available biometrics options', async () => { + it('returns PASSWORD type when user does not exist and no biometrics are available', async () => { SecureKeychain.getSupportedBiometryType = jest.fn().mockReturnValue(null); // Mock Redux store to return existingUser: false @@ -396,7 +505,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a auth type for components AUTHENTICATION_TYPE.REMEMBER_ME', async () => { + it('returns REMEMBER_ME type for components when remember me is enabled', async () => { jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); @@ -413,7 +522,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.REMEMBER_ME); }); - it('should return a auth type for components AUTHENTICATION_TYPE.PASSWORD', async () => { + it('returns PASSWORD type for components when both biometric and passcode are disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -433,7 +542,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSWORD); }); - it('should return a auth type for components AUTHENTICATION_TYPE.PASSCODE', async () => { + it('returns PASSCODE type for components when biometric is disabled', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -452,7 +561,7 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.PASSCODE); }); - it('should return a auth type for components AUTHENTICATION_TYPE.BIOMETRIC', async () => { + it('returns BIOMETRIC type for components when biometric is available', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); @@ -470,86 +579,152 @@ describe('Authentication', () => { expect(result.currentAuthType).toEqual(AUTHENTICATION_TYPE.BIOMETRIC); }); - describe('storePassword', () => { - it('should store password with BIOMETRIC authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + describe('storePassword (protected method tested via updateAuthPreference)', () => { + const mockPassword = 'test-password-123'; + let Engine: typeof import('../Engine').default; + let mockDispatch: jest.Mock; - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.BIOMETRIC); + beforeEach(() => { + Engine = jest.requireMock('../Engine'); + mockDispatch = jest.fn(); + jest.clearAllMocks(); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + user: { existingUser: true }, + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + Engine.context.KeyringController.exportSeedPhrase = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.exportSeedPhrase + >; + + jest.spyOn(Authentication, 'getPassword').mockResolvedValue({ + password: mockPassword, + username: 'metamask-user', + } as unknown as import('react-native-keychain').UserCredentials); + + jest.spyOn(Authentication, 'resetPassword').mockResolvedValue(undefined); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockResolvedValue(undefined); + + // Mock SecureKeychain methods needed by checkAuthenticationMethod + SecureKeychain.getSupportedBiometryType = jest + .fn() + .mockReturnValue(Keychain.BIOMETRY_TYPE.FACE_ID); + SecureKeychain.getGenericPassword = jest.fn().mockReturnValue(null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + StorageWrapper.clearAll(); + }); + + it('stores password with BIOMETRIC and manages storage flags correctly', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.BIOMETRICS, ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); }); - it('should store password with PASSCODE authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with PASSCODE and manages storage flags correctly', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.PASSCODE); + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSCODE, + mockPassword, + ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.PASSCODE, ); + expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); }); - it('should store password with REMEMBER_ME authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with REMEMBER_ME and does not affect biometric/passcode flags', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword( - '1234', + await Authentication.updateAuthPreference( AUTHENTICATION_TYPE.REMEMBER_ME, + mockPassword, ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith( - '1234', + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, SecureKeychain.TYPES.REMEMBER_ME, ); - }); - - it('should store password with PASSWORD authentication type', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', + // Should not remove or set biometric/passcode flags directly + expect(removeItemSpy).not.toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(removeItemSpy).not.toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).not.toHaveBeenCalledWith( + BIOMETRY_CHOICE_DISABLED, + expect.anything(), + ); + expect(setItemSpy).not.toHaveBeenCalledWith( + PASSCODE_DISABLED, + expect.anything(), + ); + // But can store previous auth type (expected behavior) + expect(setItemSpy).toHaveBeenCalledWith( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + expect.any(String), ); - - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.PASSWORD); - - expect(setGenericPasswordSpy).toHaveBeenCalledWith('1234', undefined); }); - it('should store password with UNKNOWN authentication type (default case)', async () => { - const setGenericPasswordSpy = jest.spyOn( - SecureKeychain, - 'setGenericPassword', - ); + it('stores password with PASSWORD and disables both biometric and passcode', async () => { + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); - await Authentication.storePassword('1234', AUTHENTICATION_TYPE.UNKNOWN); + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSWORD, + mockPassword, + ); - expect(setGenericPasswordSpy).toHaveBeenCalledWith('1234', undefined); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + undefined, + ); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); }); - it('should throw AuthenticationError when SecureKeychain fails', async () => { + it('throws AuthenticationError when SecureKeychain fails', async () => { const error = new Error('Keychain error'); jest .spyOn(SecureKeychain, 'setGenericPassword') .mockRejectedValueOnce(error); + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.PASSWORD, + mockPassword, + ), + ).rejects.toThrow(AuthenticationError); + try { - await Authentication.storePassword( - '1234', + await Authentication.updateAuthPreference( AUTHENTICATION_TYPE.PASSWORD, + mockPassword, ); - throw new Error('Expected an error to be thrown'); } catch (authError) { expect(authError).toBeInstanceOf(AuthenticationError); expect((authError as AuthenticationError).customErrorMessage).toBe( @@ -562,23 +737,27 @@ describe('Authentication', () => { }); it('falls back to PASSWORD authType when biometric storePassword fails in newWalletAndKeychain', async () => { - const mockDispatch = jest.fn(); + const fallbackMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: fallbackMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const fallbackEngine = jest.requireMock('../Engine'); // Mock successful vault creation - Engine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( + fallbackEngine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + fallbackEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail on first call (biometric), succeed on second (password) + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValueOnce(new Error('Biometric storage failed')) .mockResolvedValueOnce(undefined); @@ -586,7 +765,7 @@ describe('Authentication', () => { currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, }); - // Should have called storePassword twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) + // Verifies storePassword was called twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) expect(storePasswordSpy).toHaveBeenCalledTimes(2); expect(storePasswordSpy).toHaveBeenNthCalledWith( 1, @@ -599,31 +778,35 @@ describe('Authentication', () => { AUTHENTICATION_TYPE.PASSWORD, ); - // Should have completed successfully - expect(mockDispatch).toHaveBeenCalledWith(setExistingUser(true)); - expect(mockDispatch).toHaveBeenCalledWith(logIn()); + // Verifies operation completed successfully + expect(fallbackMockDispatch).toHaveBeenCalledWith(setExistingUser(true)); + expect(fallbackMockDispatch).toHaveBeenCalledWith(logIn()); storePasswordSpy.mockRestore(); }); it('falls back to PASSWORD authType when biometric storePassword fails in newWalletAndRestore', async () => { - const mockDispatch = jest.fn(); + const restoreMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: restoreMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const restoreEngine = jest.requireMock('../Engine'); // Mock successful vault restoration - Engine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( + restoreEngine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + restoreEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail on first call (biometric), succeed on second (password) + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValueOnce(new Error('Biometric storage failed')) .mockResolvedValueOnce(undefined); @@ -636,7 +819,7 @@ describe('Authentication', () => { true, ); - // Should have called storePassword twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) + // Verifies storePassword was called twice: first with BIOMETRIC (failed), then with PASSWORD (succeeded) expect(storePasswordSpy).toHaveBeenCalledTimes(2); expect(storePasswordSpy).toHaveBeenNthCalledWith( 1, @@ -649,33 +832,39 @@ describe('Authentication', () => { AUTHENTICATION_TYPE.PASSWORD, ); - // Should have completed successfully - expect(mockDispatch).toHaveBeenCalledWith(setExistingUser(true)); - expect(mockDispatch).toHaveBeenCalledWith(logIn()); + // Verifies operation completed successfully + expect(restoreMockDispatch).toHaveBeenCalledWith(setExistingUser(true)); + expect(restoreMockDispatch).toHaveBeenCalledWith(logIn()); storePasswordSpy.mockRestore(); }); it('throws error when PASSWORD authType storePassword fails in newWalletAndKeychain', async () => { - const mockDispatch = jest.fn(); + const errorMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: errorMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const errorEngine = jest.requireMock('../Engine'); - Engine.context.KeyringController.setLocked.mockResolvedValue(undefined); + errorEngine.context.KeyringController.setLocked.mockResolvedValue( + undefined, + ); // Mock successful vault creation - Engine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( + errorEngine.context.KeyringController.createNewVaultAndKeychain.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + errorEngine.resetState = jest.fn().mockResolvedValueOnce(undefined); // Mock storePassword to fail even with PASSWORD authType + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValue(new Error('Password storage failed')); try { @@ -691,36 +880,44 @@ describe('Authentication', () => { expect((error as AuthenticationError).message).toBe( 'Password storage failed', ); - // Should have called storePassword only once since it's PASSWORD authType (no fallback) + // Verifies storePassword was called only once since it's PASSWORD authType (no fallback) expect(storePasswordSpy).toHaveBeenCalledTimes(1); await Promise.resolve(); jest.runAllTimers(); - expect(mockDispatch).toHaveBeenCalledWith(logOut()); + expect(errorMockDispatch).toHaveBeenCalledWith(logOut()); } storePasswordSpy.mockRestore(); }); it('throws error when PASSWORD authType storePassword fails in newWalletAndRestore', async () => { - const mockDispatch = jest.fn(); + const restoreErrorMockDispatch = jest.fn(); jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ - dispatch: mockDispatch, + dispatch: restoreErrorMockDispatch, getState: () => ({ security: { allowLoginWithRememberMe: true } }), } as unknown as ReduxStore); - const Engine = jest.requireMock('../Engine'); + const restoreErrorEngine = jest.requireMock('../Engine'); - Engine.context.KeyringController.setLocked.mockResolvedValue(undefined); + restoreErrorEngine.context.KeyringController.setLocked.mockResolvedValue( + undefined, + ); // Mock successful vault restoration - Engine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( + restoreErrorEngine.context.KeyringController.createNewVaultAndRestore.mockResolvedValueOnce( undefined, ); - Engine.resetState = jest.fn().mockResolvedValueOnce(undefined); + restoreErrorEngine.resetState = jest + .fn() + .mockResolvedValueOnce(undefined); // Mock storePassword to fail even with PASSWORD authType + // Use type casting to access protected method for testing const storePasswordSpy = jest - .spyOn(Authentication, 'storePassword') + .spyOn( + Authentication as unknown as { storePassword: jest.Mock }, + 'storePassword', + ) .mockRejectedValue(new Error('Password storage failed')); try { @@ -741,11 +938,11 @@ describe('Authentication', () => { expect((error as AuthenticationError).message).toBe( 'Password storage failed', ); - // Should have called storePassword only once since it's PASSWORD authType (no fallback) + // Verifies storePassword was called only once since it's PASSWORD authType (no fallback) expect(storePasswordSpy).toHaveBeenCalledTimes(1); await Promise.resolve(); jest.runAllTimers(); - expect(mockDispatch).toHaveBeenCalledWith(logOut()); + expect(restoreErrorMockDispatch).toHaveBeenCalledWith(logOut()); } storePasswordSpy.mockRestore(); @@ -981,7 +1178,7 @@ describe('Authentication', () => { expect.any(Error), ); - // Should not attempt discovery due to storage error + // Does not attempt discovery due to storage error expect(mockAttemptAccountDiscovery).not.toHaveBeenCalled(); // Restore original method @@ -999,7 +1196,7 @@ describe('Authentication', () => { .fn() .mockReturnValue(mockCredentials); - // Should not throw and should complete authentication + // Does not throw and completes authentication await expect( Authentication.appTriggeredAuth(), ).resolves.not.toThrow(); @@ -1173,7 +1370,7 @@ describe('Authentication', () => { Engine.context.KeyringController.state.keyrings = [ { type: KeyringTypes.hd, metadata: { id: 'test-keyring-1' } }, { type: KeyringTypes.hd, metadata: { id: 'test-keyring-2' } }, - // Should not run discovery for this one. + // Does not run discovery for this one. { type: KeyringTypes.simple, metadata: { id: 'test-keyring-3' } }, ]; @@ -1321,7 +1518,7 @@ describe('Authentication', () => { }); describe('resetPassword', () => { - it('should call SecureKeychain.resetGenericPassword', async () => { + it('calls SecureKeychain.resetGenericPassword', async () => { const resetGenericPasswordSpy = jest.spyOn( SecureKeychain, 'resetGenericPassword', @@ -1332,7 +1529,7 @@ describe('Authentication', () => { expect(resetGenericPasswordSpy).toHaveBeenCalled(); }); - it('should throw AuthenticationError when SecureKeychain fails', async () => { + it('throws AuthenticationError when SecureKeychain fails', async () => { const error = new Error('Reset failed'); jest .spyOn(SecureKeychain, 'resetGenericPassword') @@ -1733,7 +1930,7 @@ describe('Authentication', () => { expect(OAuthService.resetOauthState).toHaveBeenCalled(); }); - it('should throw an error if first seed phrase is falsy', async () => { + it('throws error when first seed phrase is falsy', async () => { ( Engine.context.SeedlessOnboardingController .fetchAllSecretData as jest.Mock @@ -3558,6 +3755,302 @@ describe('Authentication', () => { }); }); + describe('updateAuthPreference', () => { + const mockPassword = 'test-password-123'; + + let Engine: typeof import('../Engine').default; + let mockDispatch: jest.Mock; + + beforeEach(() => { + Engine = jest.requireMock('../Engine'); + mockDispatch = jest.fn(); + jest.clearAllMocks(); + + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + Engine.context.KeyringController.exportSeedPhrase = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.exportSeedPhrase + >; + + Engine.context.KeyringController.verifyPassword = jest + .fn() + .mockResolvedValue(undefined) as jest.MockedFunction< + typeof Engine.context.KeyringController.verifyPassword + >; + + jest.spyOn(Authentication, 'getPassword').mockResolvedValue({ + password: mockPassword, + username: 'metamask-user', + } as unknown as import('react-native-keychain').UserCredentials); + + jest.spyOn(Authentication, 'resetPassword').mockResolvedValue(undefined); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + StorageWrapper.clearAll(); + }); + + it('updates auth preference to BIOMETRIC with password from keychain', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.BIOMETRIC); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to BIOMETRIC with provided password', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(Authentication.getPassword).not.toHaveBeenCalled(); + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to PASSCODE with password from keychain', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.PASSCODE); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.PASSCODE, + ); + expect(removeItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates auth preference to PASSWORD with password from keychain', async () => { + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + + // Set BIOMETRY_CHOICE so reauthenticate can find the password + await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE); + + await Authentication.updateAuthPreference(AUTHENTICATION_TYPE.PASSWORD); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect( + Engine.context.KeyringController.verifyPassword, + ).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + undefined, + ); + expect(setItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED, TRUE); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + + it('updates lock time when lockTime is -1', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: -1 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + expect(mockDispatch).toHaveBeenCalledWith( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + }); + + it('does not update lock time when lockTime is not -1', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + dispatch: mockDispatch, + getState: () => ({ + settings: { lockTime: 30000 }, + security: { allowLoginWithRememberMe: true }, + }), + } as unknown as ReduxStore); + + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + expect(mockDispatch).not.toHaveBeenCalledWith( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + }); + + it('shows alert and tracks error when password is invalid', async () => { + const invalidPasswordError = new Error('Invalid password'); + ( + Engine.context.KeyringController.verifyPassword as jest.Mock + ).mockRejectedValueOnce(invalidPasswordError); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ), + ).rejects.toThrow('Invalid password'); + + expect(alertSpy).toHaveBeenCalledWith( + strings('app_settings.invalid_password'), + strings('app_settings.invalid_password_message'), + ); + expect(trackErrorSpy).toHaveBeenCalledWith( + 'SecuritySettings: Invalid password', + 'Invalid password', + '', + ); + + alertSpy.mockRestore(); + }); + + it('logs error for non-invalid-password errors', async () => { + const otherError = new Error('Store password failed'); + jest + .spyOn(SecureKeychain, 'setGenericPassword') + .mockRejectedValueOnce(otherError); + const loggerErrorSpy = jest.spyOn(Logger, 'error'); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + await expect( + Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ), + ).rejects.toThrow('Store password failed'); + + expect(alertSpy).not.toHaveBeenCalled(); + expect(trackErrorSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy).toHaveBeenCalledWith( + expect.any(Error), + 'SecuritySettings:biometrics', + ); + + alertSpy.mockRestore(); + }); + + it('converts BIOMETRIC_NOT_ENABLED error to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS', async () => { + // Mock reauthenticate to throw BIOMETRIC_NOT_ENABLED error + const biometricNotEnabledError = new Error( + `${ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED}: Biometric is not enabled`, + ); + jest + .spyOn(Authentication, 'reauthenticate') + .mockRejectedValueOnce(biometricNotEnabledError); + + const loggerErrorSpy = jest.spyOn(Logger, 'error'); + const alertSpy = jest.spyOn(Alert, 'alert'); + const trackErrorSpy = jest.mocked(trackErrorAsAnalytics); + + // Verify the error is converted to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS + let caughtError: unknown; + try { + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + ); + } catch (error) { + caughtError = error; + } + + // Verify it throws AuthenticationError + expect(caughtError).toBeInstanceOf(AuthenticationError); + + // Verify the error has the correct customErrorMessage + expect((caughtError as AuthenticationError).customErrorMessage).toBe( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + ); + + // Verify that invalid password handling was not triggered + expect(alertSpy).not.toHaveBeenCalled(); + expect(trackErrorSpy).not.toHaveBeenCalled(); + + // Verify that Logger.error was not called (since this is a converted error) + expect(loggerErrorSpy).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('skips password validation when skipValidation is true', async () => { + const removeItemSpy = jest.spyOn(StorageWrapper, 'removeItem'); + const setItemSpy = jest.spyOn(StorageWrapper, 'setItem'); + const verifyPasswordSpy = jest.spyOn( + Engine.context.KeyringController, + 'verifyPassword', + ); + + // Note: The actual implementation doesn't have skipValidation parameter + // This test should verify normal behavior + await Authentication.updateAuthPreference( + AUTHENTICATION_TYPE.BIOMETRIC, + mockPassword, + ); + + expect(Authentication.resetPassword).toHaveBeenCalledTimes(1); + expect(verifyPasswordSpy).toHaveBeenCalledWith(mockPassword); + expect(SecureKeychain.setGenericPassword).toHaveBeenCalledWith( + mockPassword, + SecureKeychain.TYPES.BIOMETRICS, + ); + expect(removeItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE_DISABLED); + expect(setItemSpy).toHaveBeenCalledWith(PASSCODE_DISABLED, TRUE); + expect(mockDispatch).toHaveBeenCalledWith(passwordSet()); + }); + }); describe('checkAndShowSeedlessPasswordOutdatedModal', () => { let Engine: typeof import('../Engine').default; let mockIsOutdated: boolean = false; diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index fab5a8d8fb4..5433feb2d8a 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -7,6 +7,7 @@ import { PASSCODE_DISABLED, SEED_PHRASE_HINTS, OPTIN_META_METRICS_UI_SEEN, + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, BIOMETRY_CHOICE, } from '../../constants/storage'; import { @@ -78,7 +79,11 @@ import { EntropySourceId } from '@metamask/keyring-api'; import { trackVaultCorruption } from '../../util/analytics/vaultCorruptionTracking'; import MetaMetrics from '../Analytics/MetaMetrics'; import { resetProviderToken as depositResetProviderToken } from '../../components/UI/Ramp/Deposit/utils/ProviderTokenVault'; +import { Alert } from 'react-native'; import { strings } from '../../../locales/i18n'; +import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAnalytics'; +import AppConstants from '../AppConstants'; +import { setLockTime } from '../../actions/settings'; import { IconName } from '../../component-library/components/Icons/Icon'; import { ReauthenticateErrorType } from './types'; @@ -350,6 +355,20 @@ class AuthenticationService { const passcodePreviouslyDisabled = await StorageWrapper.getItem(PASSCODE_DISABLED); + // Remember me should take priority over biometric/passcode + const existingUser = selectExistingUser(ReduxService.store.getState()); + const allowLoginWithRememberMe = + ReduxService.store.getState().security?.allowLoginWithRememberMe; + if (existingUser && allowLoginWithRememberMe) { + const credentials = await SecureKeychain.getGenericPassword(); + if (credentials && credentials.password) { + return { + currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, + availableBiometryType, + }; + } + } + if ( availableBiometryType && !(biometryPreviouslyDisabled && biometryPreviouslyDisabled === TRUE) @@ -358,7 +377,9 @@ class AuthenticationService { currentAuthType: AUTHENTICATION_TYPE.BIOMETRIC, availableBiometryType, }; - } else if ( + } + // Then check passcode + if ( availableBiometryType && !(passcodePreviouslyDisabled && passcodePreviouslyDisabled === TRUE) ) { @@ -367,15 +388,7 @@ class AuthenticationService { availableBiometryType, }; } - const existingUser = selectExistingUser(ReduxService.store.getState()); - if (existingUser) { - if (await SecureKeychain.getGenericPassword()) { - return { - currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, - availableBiometryType, - }; - } - } + // Default to password return { currentAuthType: AUTHENTICATION_TYPE.PASSWORD, availableBiometryType, @@ -396,39 +409,81 @@ class AuthenticationService { }; /** - * Stores a user password in the secure keychain with a specific auth type + * Stores a user password in the secure keychain with a specific auth type. + * This is the single source of truth for password persistence and manages + * all related storage flags to ensure authentication types are mutually exclusive. + * * @param password - password provided by user * @param authType - type of authentication required to fetch password from keychain + * @protected */ - storePassword = async ( + protected storePassword = async ( password: string, authType: AUTHENTICATION_TYPE, ): Promise => { try { + // Store password in keychain with appropriate type switch (authType) { case AUTHENTICATION_TYPE.BIOMETRIC: await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.BIOMETRICS, ); + await StorageWrapper.removeItem(BIOMETRY_CHOICE_DISABLED); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); + break; case AUTHENTICATION_TYPE.PASSCODE: await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.PASSCODE, ); + await StorageWrapper.removeItem(PASSCODE_DISABLED); + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); break; - case AUTHENTICATION_TYPE.REMEMBER_ME: + case AUTHENTICATION_TYPE.REMEMBER_ME: { + // Store the current auth type before switching to remember me + const currentAuthData = await this.checkAuthenticationMethod(); + // Only store if we're not already on remember me + if ( + currentAuthData.currentAuthType !== AUTHENTICATION_TYPE.REMEMBER_ME + ) { + await StorageWrapper.setItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + currentAuthData.currentAuthType, + ); + } + await SecureKeychain.setGenericPassword( password, SecureKeychain.TYPES.REMEMBER_ME, ); + // SecureKeychain.setGenericPassword handles flag management for REMEMBER_ME + // (sets BIOMETRY_CHOICE_DISABLED and PASSCODE_DISABLED to disable biometric/passcode) break; - case AUTHENTICATION_TYPE.PASSWORD: + } + case AUTHENTICATION_TYPE.PASSWORD: { await SecureKeychain.setGenericPassword(password, undefined); + // Password only: disable both biometrics and passcode + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); + + // If remember me is enabled, clear the stored previous auth type + // because the user is disabling biometrics/passcode, so we shouldn't restore to them + const allowLoginWithRememberMe = + ReduxService.store.getState().security?.allowLoginWithRememberMe; + if (allowLoginWithRememberMe) { + await StorageWrapper.removeItem( + PREVIOUS_AUTH_TYPE_BEFORE_REMEMBER_ME, + ); + } break; + } default: await SecureKeychain.setGenericPassword(password, undefined); + // Default to password behavior: disable both + await StorageWrapper.setItem(BIOMETRY_CHOICE_DISABLED, TRUE); + await StorageWrapper.setItem(PASSCODE_DISABLED, TRUE); break; } } catch (error) { @@ -1422,6 +1477,81 @@ class AuthenticationService { } } + /** + * Updates the authentication preference for the user. + * If password is provided, uses it directly. Otherwise, gets password from keychain. + * Validates the password and stores it with the new auth type. + * Manages storage flags (BIOMETRY_CHOICE_DISABLED, PASSCODE_DISABLED) based on auth type. + * Throws AuthenticationError if password is not found in keychain and not provided. + * Callers should handle navigation to password entry screen when this error is thrown. + * + * @param authType - type of authentication to use (BIOMETRIC, PASSCODE, or PASSWORD) + * @param password - optional password to use. If not provided, gets from keychain. + * @returns {Promise} + * @throws {AuthenticationError} when password is not found and not provided + */ + updateAuthPreference = async ( + authType: AUTHENTICATION_TYPE = AUTHENTICATION_TYPE.PASSWORD, + password?: string, + ): Promise => { + // Password found or provided. Validate and update the auth preference. + try { + const passwordToUse = await this.reauthenticate(password); + if (!passwordToUse.password) { + throw new AuthenticationError( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + this.authData, + ); + } + // TUDO: Check if this is really needed for IOS + await this.resetPassword(); + + // storePassword handles all storage flag management internally + await this.storePassword(passwordToUse.password, authType); + + ReduxService.store.dispatch(passwordSet()); + + const lockTime = ReduxService.store.getState().settings.lockTime; + if (lockTime === -1) { + ReduxService.store.dispatch( + setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT), + ); + } + } catch (e) { + const errorWithMessage = e as { message: string }; + + // Check if the error is because biometrics are not enabled + // Convert it to AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS so UI can handle it + if ( + errorWithMessage.message.includes( + ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED, + ) + ) { + throw new AuthenticationError( + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS, + this.authData, + ); + } + + if (errorWithMessage.message === 'Invalid password') { + Alert.alert( + strings('app_settings.invalid_password'), + strings('app_settings.invalid_password_message'), + ); + trackErrorAsAnalytics( + 'SecuritySettings: Invalid password', + errorWithMessage?.message, + '', + ); + } else { + Logger.error(e as unknown as Error, 'SecuritySettings:biometrics'); + } + throw e; + } + }; + /** * If a password is provided, it is verified directly. Otherwise, this method * attempts to read the biometric preference from storage and, when enabled, From 8368dba1f8df0bc47b76d0ec97c0f3e1a48e4b7d Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Thu, 18 Dec 2025 10:06:10 -0800 Subject: [PATCH 04/10] fix: check for valid dest account instead of dest account chain (#24095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** There was a problem where changing dest token to one on a different network (switching between non-EVM networks in particular) does not update the destination wallet address, causing the 'get quotes' functionality to fail. This is solved by checking for valid destination accounts rather than just checking the chain ID. ## **Changelog** CHANGELOG entry: Fixed bug where requesting quotes for successive non-EVM networks failed ## **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. --- > [!NOTE] > Recipient address initialization now validates against destination accounts and reinitializes when switching chains, fixing non-EVM chain switching issues. > > - **Bridge UI**: > - **useRecipientInitialization** (`app/components/UI/Bridge/hooks/useRecipientInitialization.ts`): > - Add `isDestAddressValidForDestChain` (`useMemo`) to verify `destAddress` exists in filtered `destinationAccounts` (using `areAddressesEqual`). > - Replace chain-type checks with account-validity check and reinitialize recipient when address is missing/invalid for selected chain. > - Update imports and effect dependencies (`useMemo`, `areAddressesEqual`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 656afb72a2480ede3303869da4b5fb79884a103e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useRecipientInitialization.ts | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useRecipientInitialization.ts b/app/components/UI/Bridge/hooks/useRecipientInitialization.ts index cc957becd76..f95817dcb6c 100644 --- a/app/components/UI/Bridge/hooks/useRecipientInitialization.ts +++ b/app/components/UI/Bridge/hooks/useRecipientInitialization.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { selectDestAddress, @@ -7,11 +7,8 @@ import { } from '../../../../core/redux/slices/bridge'; import { CaipAccountId, parseCaipAccountId } from '@metamask/utils'; import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController'; -import { - isNonEvmAddress, - isNonEvmChainId, -} from '../../../../core/Multichain/utils'; import { useDestinationAccounts } from './useDestinationAccounts'; +import { areAddressesEqual } from '../../../../util/address'; export const useRecipientInitialization = ( hasInitializedRecipient: React.MutableRefObject, @@ -33,6 +30,26 @@ export const useRecipientInitialization = ( [dispatch], ); + // Check if current destAddress is a valid destination account for the current destination chain + // This properly handles switching between different non-EVM chains (e.g., BTC → SOL) + // by checking if the address exists in the filtered destination accounts list + const isDestAddressValidForDestChain = useMemo(() => { + if ( + !destAddress || + !destToken?.chainId || + destinationAccounts.length === 0 + ) { + return false; + } + + // Check if the current destAddress matches any of the valid destination accounts + // destinationAccounts is already filtered by selectValidDestInternalAccountIds + // which uses account scopes to filter for the specific destination chain + return destinationAccounts.some((account) => + areAddressesEqual(account.address, destAddress), + ); + }, [destAddress, destToken?.chainId, destinationAccounts]); + // Initialize default recipient account useEffect(() => { // Only initialize if we haven't done so before, or if the current address doesn't match the network type @@ -40,25 +57,12 @@ export const useRecipientInitialization = ( return; } - // Check if current destAddress matches the destination chain type - const isDestChainNonEvm = - destToken?.chainId && isNonEvmChainId(destToken.chainId); - const isDestAddressNonEvm = destAddress && isNonEvmAddress(destAddress); - - // Address format should match the destination chain type: - // - If dest chain is non-EVM (e.g., Solana, Bitcoin), dest address should be non-EVM - // - If dest chain is EVM, dest address should be EVM - const doesDestAddrMatchNetworkType = - destAddress && - destToken?.chainId && - isDestChainNonEvm === isDestAddressNonEvm; - - // Only initialize in these specific cases: - // 1. Never initialized AND no destAddress set - // 2. destAddress doesn't match the current network type (user switched networks) - const shouldInitialize = - (!hasInitializedRecipient.current && !destAddress) || - !doesDestAddrMatchNetworkType; + // Initialize/reinitialize in these cases: + // 1. No destAddress is set (missing or cleared) + // 2. destAddress is not valid for the current destination chain (user switched networks) + // This handles switching between different non-EVM chains (e.g., BTC → SOL) + // Note: isDestAddressValidForDestChain returns false when destAddress is falsy, + const shouldInitialize = !isDestAddressValidForDestChain; if (shouldInitialize) { // Find an account from the currently selected account group that supports the destination network @@ -78,10 +82,10 @@ export const useRecipientInitialization = ( } }, [ destAddress, - destToken, destinationAccounts, handleSelectAccount, currentlySelectedAccount, hasInitializedRecipient, + isDestAddressValidForDestChain, ]); }; From 519269649bbd01e016f7416a048c8d4c4c434a50 Mon Sep 17 00:00:00 2001 From: AxelGes <34173844+AxelGes@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:58:00 -0300 Subject: [PATCH 05/10] feat(deposit): add locale support to native-ramps SDK (#24093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Pass the user's locale to the NativeRampsSdk instances in the Deposit SDK provider. This ensures the SDK can display localized content based on the user's language settings. [SDK PR](https://github.com/consensys-vertical-apps/va-mmcx-native-ramps-sdk/pull/71) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TRAM-2934?atlOrigin=eyJpIjoiZTlmYTFiMzAzOGZmNDA1Yzk3NWIwMWRjOTI0NDE1YTEiLCJwIjoiaiJ9 ## **Manual testing steps** N/A - Internal SDK configuration change. ## **Screenshots/Recordings** N/A - Internal SDK configuration change with no visible UI changes. ### **Before** N/A ### **After** N/A ## **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 - [ ] 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. --- > [!NOTE] > Passes the user’s locale to `NativeRampsSdk` (and updates on changes) in the Deposit SDK; updates tests and bumps `@consensys/native-ramps-sdk` to ^2.1.7. > > - **Deposit SDK (Provider)**: > - Initialize `NativeRampsSdk` with `locale: I18n.locale` for both authenticated instances and `DepositSDKNoAuth` in `app/components/UI/Ramp/Deposit/sdk/index.tsx`. > - Listen to `I18nEvents.localeChanged` to call `sdk.setLocale(locale)` and `DepositSDKNoAuth.setLocale(locale)`. > - Export `DEPOSIT_ENVIRONMENT` (from `getSdkEnvironment`). > - Minor: refine logout error handling typing. > - **Tests** (`app/components/UI/Ramp/Deposit/sdk/index.test.tsx`): > - Expect `locale: 'en'` in `NativeRampsSdk` initialization. > - Replace `SdkEnvironment.Staging` with `DEPOSIT_ENVIRONMENT` usage. > - **Dependencies**: > - Bump `@consensys/native-ramps-sdk` to `^2.1.7` (lockfile updated). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 63345bbf36c6b4cc40819c009263458f0b230fda. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Ramp/Deposit/sdk/index.test.tsx | 10 ++++---- app/components/UI/Ramp/Deposit/sdk/index.tsx | 23 +++++++++++++++++-- package.json | 2 +- yarn.lock | 10 ++++---- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/components/UI/Ramp/Deposit/sdk/index.test.tsx b/app/components/UI/Ramp/Deposit/sdk/index.test.tsx index 9df141875c9..67ec01e2735 100644 --- a/app/components/UI/Ramp/Deposit/sdk/index.test.tsx +++ b/app/components/UI/Ramp/Deposit/sdk/index.test.tsx @@ -6,6 +6,7 @@ import { DepositSDKContext, DepositSDKProvider, useDepositSDK, + DEPOSIT_ENVIRONMENT, } from '.'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { @@ -15,11 +16,7 @@ import { } from '../testUtils'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; -import { - NativeRampsSdk, - SdkEnvironment, - Context, -} from '@consensys/native-ramps-sdk'; +import { NativeRampsSdk, Context } from '@consensys/native-ramps-sdk'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ @@ -157,8 +154,9 @@ describe('Deposit SDK Context', () => { { apiKey: 'test-provider-api-key', context: Context.MobileIOS, + locale: 'en', }, - SdkEnvironment.Staging, + DEPOSIT_ENVIRONMENT, ); }); }); diff --git a/app/components/UI/Ramp/Deposit/sdk/index.tsx b/app/components/UI/Ramp/Deposit/sdk/index.tsx index 8a04728f236..742d5968fb1 100644 --- a/app/components/UI/Ramp/Deposit/sdk/index.tsx +++ b/app/components/UI/Ramp/Deposit/sdk/index.tsx @@ -33,7 +33,7 @@ import { setFiatOrdersPaymentMethodDeposit, } from '../../../../../reducers/fiatOrders'; import Logger from '../../../../../util/Logger'; -import { strings } from '../../../../../../locales/i18n'; +import I18n, { I18nEvents, strings } from '../../../../../../locales/i18n'; import useRampAccountAddress from '../../hooks/useRampAccountAddress'; import { DepositNavigationParams } from '../types'; @@ -73,10 +73,15 @@ export const DEPOSIT_ENVIRONMENT = environment; export const DepositSDKNoAuth = new NativeRampsSdk( { context, + locale: I18n.locale, }, environment, ); +I18nEvents.addListener('localeChanged', (locale) => { + DepositSDKNoAuth.setLocale(locale); +}); + export const DepositSDKContext = createContext( undefined, ); @@ -151,6 +156,7 @@ export const DepositSDKProvider = ({ { apiKey: providerApiKey, context, + locale: I18n.locale, }, environment, ); @@ -161,6 +167,19 @@ export const DepositSDKProvider = ({ } }, [providerApiKey]); + // Listen for locale changes and update SDK locale + useEffect(() => { + if (!sdk) return; + + const handleLocaleChange = (locale: string) => { + sdk.setLocale(locale); + }; + I18nEvents.addListener('localeChanged', handleLocaleChange); + return () => { + I18nEvents.removeListener('localeChanged', handleLocaleChange); + }; + }, [sdk]); + useEffect(() => { if (sdk && authToken) { sdk.setAccessToken(authToken); @@ -216,7 +235,7 @@ export const DepositSDKProvider = ({ ? await sdk.logout() : await sdk .logout() - .catch((error) => + .catch((error: Error) => Logger.error( error as Error, 'SDK logout failed but invalidation was not required. Error:', diff --git a/package.json b/package.json index 13c24b49715..3842a589095 100644 --- a/package.json +++ b/package.json @@ -180,7 +180,7 @@ }, "dependencies": { "@config-plugins/detox": "^9.0.0", - "@consensys/native-ramps-sdk": "2.1.6", + "@consensys/native-ramps-sdk": "^2.1.7", "@consensys/on-ramp-sdk": "2.1.12", "@craftzdog/react-native-buffer": "^6.1.0", "@ethersproject/abi": "^5.7.0", diff --git a/yarn.lock b/yarn.lock index e30f2c540f5..062a3159745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2049,9 +2049,9 @@ __metadata: languageName: node linkType: hard -"@consensys/native-ramps-sdk@npm:2.1.6": - version: 2.1.6 - resolution: "@consensys/native-ramps-sdk@npm:2.1.6" +"@consensys/native-ramps-sdk@npm:^2.1.7": + version: 2.1.7 + resolution: "@consensys/native-ramps-sdk@npm:2.1.7" dependencies: "@metamask/utils": "npm:^11.5.0" async: "npm:^3.2.3" @@ -2060,7 +2060,7 @@ __metadata: crypto-js: "npm:^4.2.0" reflect-metadata: "npm:^0.1.13" uuid: "npm:^9.0.0" - checksum: 10/0d98a744366dcc7a0b6954540c1c5abc5783a12692debb17363c773277793f327d669d9ddf20e596126ab41affa504faf07a677db445078facff9abbe603fd9d + checksum: 10/52c8a7911861dce0b2828c1dee039ffd40934e343bdda4a29b742cc104919c477f033dea95b6686a7cbf03c4ef21bcf1c7617ae000001d617fc618189d6cabc4 languageName: node linkType: hard @@ -34134,7 +34134,7 @@ __metadata: "@babel/register": "npm:^7.24.6" "@babel/runtime": "npm:^7.25.0" "@config-plugins/detox": "npm:^9.0.0" - "@consensys/native-ramps-sdk": "npm:2.1.6" + "@consensys/native-ramps-sdk": "npm:^2.1.7" "@consensys/on-ramp-sdk": "npm:2.1.12" "@craftzdog/react-native-buffer": "npm:^6.1.0" "@ethersproject/abi": "npm:^5.7.0" From f39bc2821d6fbbd8e1b69d1979b3368b456b17d1 Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Thu, 18 Dec 2025 19:38:35 +0000 Subject: [PATCH 06/10] test: fixes mockserver lifecycle and unmocked calls (#24158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixes the lifecycle of the mock server teardown that was causing issues with not validating live requests. ## **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. --- > [!NOTE] > Adds Contentful banner mocks, fixes rewards API mocking (methods/endpoints) while removing an allowlisted URL, and reorders cleanup to safely reload RN before mock server shutdown. > > - **E2E API mocking**: > - **Contentful**: Add default mocks for promotional banner queries in `defaults/contentful-banners.ts` and include in `defaults/index.ts`. > - **Rewards API**: Correct HTTP methods and coverage in `defaults/rewards.ts`: > - `POST /auth/mobile-login` → mocked 401; `POST /public/rewards/ois` → mocked 200 with empty list. > - Add `GET /public/seasons/status` and `GET /public/seasons/{id}/metadata` → mocked 200. > - **Allowlist**: Remove `https://rewards.dev-api.cx.metamask.io/auth/mobile-login` from `mock-e2e-allowlist.ts`. > - **Fixture/cleanup lifecycle**: > - In `FixtureHelper.ts`, reload React Native with synchronization disabled/enabled and perform it before mock server shutdown; validate live requests earlier; maintain robust error handling. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 49a79e6f2bcbb6e7a0a81a92644c04419f94ecad. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- e2e/api-mocking/mock-e2e-allowlist.ts | 1 - .../defaults/contentful-banners.ts | 55 +++++++++++++++++++ .../mock-responses/defaults/index.ts | 2 + .../mock-responses/defaults/rewards.ts | 26 +++++++-- e2e/framework/fixtures/FixtureHelper.ts | 49 ++++++++++------- 5 files changed, 107 insertions(+), 26 deletions(-) create mode 100644 e2e/api-mocking/mock-responses/defaults/contentful-banners.ts diff --git a/e2e/api-mocking/mock-e2e-allowlist.ts b/e2e/api-mocking/mock-e2e-allowlist.ts index 72072325c7b..179bcdf0f78 100644 --- a/e2e/api-mocking/mock-e2e-allowlist.ts +++ b/e2e/api-mocking/mock-e2e-allowlist.ts @@ -39,7 +39,6 @@ export const ALLOWLISTED_URLS = [ 'https://mainnet.era.zksync.io/', 'https://eth.llamarpc.com/', 'https://rpc.atlantischain.network/', - 'https://rewards.dev-api.cx.metamask.io/auth/mobile-login', 'https://nft.api.cx.metamask.io/collections?chainId=0x539&contract=0xb2552e4f4bc23e1572041677234d192774558bf0', 'https://metamask.github.io/test-dapp/metamask-fox.svg', 'https://dapp-scanning.api.cx.metamask.io/bulk-scan', diff --git a/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts b/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts new file mode 100644 index 00000000000..ec57ccb2160 --- /dev/null +++ b/e2e/api-mocking/mock-responses/defaults/contentful-banners.ts @@ -0,0 +1,55 @@ +import { MockEventsObject } from '../../../framework'; + +export const CONTENTFUL_BANNERS_MOCKS: MockEventsObject = { + GET: [ + { + urlEndpoint: + /https:\/\/cdn\.contentful\.com.*content_type=promotionalBanner/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + { + urlEndpoint: /contentful\.com.*promotionalBanner/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + { + urlEndpoint: /contentful\.com.*showInMobile.*true/, + responseCode: 200, + response: { + sys: { + type: 'Array', + }, + total: 0, + skip: 0, + limit: 100, + items: [], + includes: { + Asset: [], + }, + }, + }, + ], +}; diff --git a/e2e/api-mocking/mock-responses/defaults/index.ts b/e2e/api-mocking/mock-responses/defaults/index.ts index 3f471d824cb..85c82fbc477 100644 --- a/e2e/api-mocking/mock-responses/defaults/index.ts +++ b/e2e/api-mocking/mock-responses/defaults/index.ts @@ -25,6 +25,7 @@ import { INFURA_MOCKS } from '../infura-mocks'; import { CHAINS_NETWORK_MOCK_RESPONSE } from '../chains-network-mocks'; import { DEFAULT_REWARDS_MOCKS } from './rewards'; import { ACL_EXECUTION_MOCKS } from './acl-execution'; +import { CONTENTFUL_BANNERS_MOCKS } from './contentful-banners'; // Get auth mocks const authMocks = getAuthMocks(); @@ -49,6 +50,7 @@ export const DEFAULT_MOCKS = { ...(INFURA_MOCKS.GET || []), ...(DEFAULT_REWARDS_MOCKS.GET || []), ...(ACL_EXECUTION_MOCKS.GET || []), + ...(CONTENTFUL_BANNERS_MOCKS.GET || []), // Chains Network Mock - Provides blockchain network data { urlEndpoint: 'https://chainid.network/chains.json', diff --git a/e2e/api-mocking/mock-responses/defaults/rewards.ts b/e2e/api-mocking/mock-responses/defaults/rewards.ts index 2226e06236c..96bbc3d1320 100644 --- a/e2e/api-mocking/mock-responses/defaults/rewards.ts +++ b/e2e/api-mocking/mock-responses/defaults/rewards.ts @@ -6,7 +6,15 @@ import { MockEventsObject } from '../../../framework'; */ export const DEFAULT_REWARDS_MOCKS: MockEventsObject = { - GET: [ + POST: [ + { + urlEndpoint: + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/auth\/mobile-login$/, + responseCode: 401, + response: { + error: 'Unauthorized', + }, + }, { urlEndpoint: /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/rewards\/ois$/, @@ -16,14 +24,22 @@ export const DEFAULT_REWARDS_MOCKS: MockEventsObject = { }, }, ], - POST: [ + GET: [ { urlEndpoint: - /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/auth\/mobile-login$/, - responseCode: 401, + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/seasons\/status$/, + responseCode: 200, response: { - error: 'Unauthorized', + previous: null, + current: {}, + next: null, }, }, + { + urlEndpoint: + /^https:\/\/rewards\.(uat|dev)-api\.cx\.metamask\.io\/public\/seasons\/[a-f0-9-]+\/metadata$/, + responseCode: 200, + response: {}, + }, ], }; diff --git a/e2e/framework/fixtures/FixtureHelper.ts b/e2e/framework/fixtures/FixtureHelper.ts index 5c32d5df30c..73b8f43d612 100644 --- a/e2e/framework/fixtures/FixtureHelper.ts +++ b/e2e/framework/fixtures/FixtureHelper.ts @@ -660,6 +660,35 @@ export async function withFixtures( } } + // skipReactNativeReload needs to happen before killing the mock server to avoid race conditions + if (!skipReactNativeReload) { + try { + // Disable synchronization to prevent race conditions with pending timers + await device.disableSynchronization(); + await device.reloadReactNative(); + await device.enableSynchronization(); + } catch (cleanupError) { + logger.warn('React Native reload failed (non-critical):', cleanupError); + // Ensure synchronization is re-enabled even on failure + try { + await device.enableSynchronization(); + } catch { + // Ignore - best effort + } + // Don't add to cleanupErrors as this is a non-critical cleanup operation + } + } + + if (mockServerInstance) { + try { + // Validate live requests + mockServerInstance.validateLiveRequests(); + } catch (cleanupError) { + logger.error('Error during live request validation:', cleanupError); + cleanupErrors.push(cleanupError as Error); + } + } + // Clean up the mock server if (mockServerInstance?.isStarted()) { try { @@ -695,26 +724,6 @@ export async function withFixtures( } } - if (!skipReactNativeReload) { - try { - // Force reload React Native to stop any lingering timers - await device.reloadReactNative(); - } catch (cleanupError) { - logger.warn('React Native reload failed (non-critical):', cleanupError); - // Don't add to cleanupErrors as this is a non-critical cleanup operation - } - } - - if (mockServerInstance) { - try { - // Validate live requests - mockServerInstance.validateLiveRequests(); - } catch (cleanupError) { - logger.error('Error during live request validation:', cleanupError); - cleanupErrors.push(cleanupError as Error); - } - } - // Handle error reporting: prioritize test error over cleanup errors if (testError && cleanupErrors.length > 0) { // Both test and cleanup failed - report both but throw the test error From f229330558008e1be043385b29708bb31c4e9fea Mon Sep 17 00:00:00 2001 From: jiexi Date: Thu, 18 Dec 2025 12:01:35 -0800 Subject: [PATCH 07/10] feat: Clear stale connection approvals when user tries back to back connection attempts on MetaMask Connect (#24119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Improves UX for MetaMask Connect users in the scenario where they: 1. connect from a dapp via Native Browser 2. go to the wallet 3. do not accept/reject/see the first connection approval 4. return to the dapp 5. attempt a new connection 6. Revisit the wallet and except to see a new connection approval but still see the old one (where accepting the old connection approval will not result in what the user intends, i.e. a working connection) It does this by checking to see if the incoming MMC request is for wallet_createSession and then clearing any pending approvals to ensure stale approvals are cleared out. This does have the side effect of clearing approvals that are not MMC, but is highly unlikely to occur, and can be recovered from gracefully by simply retrying the original request. ## **Changelog** CHANGELOG entry: null MetaMask Connect has not been released to public yet ## **Related issues** Fixes: ## **Manual testing steps** 1. connect from a dapp via Native Browser 2. go to the wallet 3. do not accept/reject/see the first connection approval 4. return to the dapp 5. attempt a new connection 6. Revisit the wallet 7. The first connection approval should disappear automatically 8. Shortly after the new connection approval should appear 9. Accept the connection approval 10. Go back to the dapp 11. You should be connected ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/a93cffe5-de80-4dc6-a41c-910db1bbde06 ### **After** https://github.com/user-attachments/assets/ea154365-9171-4ad8-b6ec-98d25c16cd9c ## **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. --- > [!NOTE] > When receiving `wallet_createSession` from the multichain provider, clear any pending approvals and navigate back before forwarding, with tests updated to cover behavior and new payload shape. > > - **Connection handling (`app/core/SDKConnectV2/services/connection.ts`)**: > - Detects `wallet_createSession` messages from `metamask-multichain-provider`. > - If pending approvals exist: calls `NavigationService.navigation?.goBack()` and clears approvals via `ApprovalController.clear(providerErrors.userRejectedRequest({ data: { cause: 'rejectAllApprovals' } }))`. > - Continues forwarding the original payload to the bridge. > - **Tests (`app/core/SDKConnectV2/services/connection.test.ts`)**: > - Updates payload shape to `{ name, data }` for messages/responses. > - Adds tests for clearing vs. not clearing approvals based on pending count. > - Mocks `Engine.ApprovalController` and `NavigationService` to verify navigation and clearing behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit c432aa89a2b7f98c1d5cecaa8e856334e45db6c6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Alex Donesky --- .../SDKConnectV2/services/connection.test.ts | 98 ++++++++++++++++++- app/core/SDKConnectV2/services/connection.ts | 37 ++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/app/core/SDKConnectV2/services/connection.test.ts b/app/core/SDKConnectV2/services/connection.test.ts index d2ad82915d2..051e75f8ff1 100644 --- a/app/core/SDKConnectV2/services/connection.test.ts +++ b/app/core/SDKConnectV2/services/connection.test.ts @@ -13,7 +13,9 @@ import { KVStore } from '../store/kv-store'; import { RPCBridgeAdapter } from '../adapters/rpc-bridge-adapter'; import { ConnectionInfo } from '../types/connection-info'; import { HostApplicationAdapter } from '../adapters/host-application-adapter'; -import { errorCodes } from '@metamask/rpc-errors'; +import { errorCodes, providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../Engine'; +import NavigationService from '../../NavigationService'; jest.mock('@metamask/mobile-wallet-protocol-wallet-client'); jest.mock('@metamask/mobile-wallet-protocol-core', () => ({ @@ -25,6 +27,19 @@ jest.mock('@metamask/mobile-wallet-protocol-core', () => ({ })); jest.mock('../store/kv-store'); jest.mock('../adapters/rpc-bridge-adapter'); +jest.mock('../../Engine', () => ({ + context: { + ApprovalController: { + getTotalApprovalCount: jest.fn(), + clear: jest.fn().mockResolvedValue(undefined), + }, + }, +})); +jest.mock('../../NavigationService', () => ({ + navigation: { + goBack: jest.fn(), + }, +})); const MockedWalletClient = WalletClient as jest.MockedClass< typeof WalletClient @@ -195,7 +210,10 @@ describe('Connection', () => { mockHostApp, ); - const dAppPayload = { id: 1, method: 'eth_accounts', params: [] }; + const dAppPayload = { + name: 'metamask-provider', + data: { id: 1, method: 'eth_accounts', params: [] }, + }; // Simulate the WalletClient receiving a message onClientMessageCallback(dAppPayload); @@ -211,7 +229,10 @@ describe('Connection', () => { mockHostApp, ); - const walletPayload = { id: 1, result: ['0x123'] }; + const walletPayload = { + name: 'metamask-provider', + data: { id: 1, result: ['0x123'] }, + }; // Simulate the RPCBridgeAdapter emitting a response onBridgeResponseCallback(walletPayload); @@ -220,6 +241,77 @@ describe('Connection', () => { walletPayload, ); }); + + describe('wallet_createSession request', () => { + it('clears all pending approvals and navigates away from the open approval modal when there are pending approval requests', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + ( + Engine.context.ApprovalController.getTotalApprovalCount as jest.Mock + ).mockReturnValue(2); + + const walletCreateSessionPayload = { + name: 'metamask-multichain-provider', + data: { + method: 'wallet_createSession', + params: {}, + id: 1, + }, + }; + + await onClientMessageCallback(walletCreateSessionPayload); + + expect(NavigationService.navigation?.goBack).toHaveBeenCalledTimes(1); + expect(Engine.context.ApprovalController.clear).toHaveBeenCalledTimes( + 1, + ); + expect(Engine.context.ApprovalController.clear).toHaveBeenCalledWith( + providerErrors.userRejectedRequest({ + data: { + cause: 'rejectAllApprovals', + }, + }), + ); + expect(mockBridgeInstance.send).toHaveBeenCalledWith( + walletCreateSessionPayload, + ); + }); + + it('does not clear pending approvals or navigate away when there are no pending approval requests', async () => { + await Connection.create( + mockConnectionInfo, + mockKeyManager, + RELAY_URL, + mockHostApp, + ); + + ( + Engine.context.ApprovalController.getTotalApprovalCount as jest.Mock + ).mockReturnValue(0); + + const walletCreateSessionPayload = { + name: 'metamask-multichain-provider', + data: { + method: 'wallet_createSession', + params: {}, + id: 1, + }, + }; + + await onClientMessageCallback(walletCreateSessionPayload); + + expect(NavigationService.navigation?.goBack).not.toHaveBeenCalled(); + expect(Engine.context.ApprovalController.clear).not.toHaveBeenCalled(); + expect(mockBridgeInstance.send).toHaveBeenCalledWith( + walletCreateSessionPayload, + ); + }); + }); }); describe('resume', () => { diff --git a/app/core/SDKConnectV2/services/connection.ts b/app/core/SDKConnectV2/services/connection.ts index ea4b6384571..a19afc3ae76 100644 --- a/app/core/SDKConnectV2/services/connection.ts +++ b/app/core/SDKConnectV2/services/connection.ts @@ -13,7 +13,9 @@ import { RPCBridgeAdapter } from '../adapters/rpc-bridge-adapter'; import { ConnectionInfo } from '../types/connection-info'; import logger from './logger'; import { IHostApplicationAdapter } from '../types/host-application-adapter'; -import { errorCodes } from '@metamask/rpc-errors'; +import { errorCodes, providerErrors } from '@metamask/rpc-errors'; +import Engine from '../../Engine'; +import NavigationService from '../../NavigationService'; /** * Connection is a live, runtime representation of a dApp connection. @@ -36,9 +38,40 @@ export class Connection { this.hostApp = hostApp; this.bridge = new RPCBridgeAdapter(this.info); - this.client.on('message', (payload) => { + this.client.on('message', async (payload) => { logger.debug('Received message:', this.id, payload); + const isWalletCreateSessionRequest = + payload && + typeof payload === 'object' && + 'name' in payload && + payload.name === 'metamask-multichain-provider' && + 'data' in payload && + payload.data && + typeof payload.data === 'object' && + 'method' in payload.data && + payload.data.method === 'wallet_createSession'; + + // If the request is a wallet_createSession request and there are pending approval requests, clear those pending approvals before + // showing the wallet_createSession approval. We do this to prevent the user from seeing a stale wallet_createSession approval in the + // scenario where they make a connection request, but leave the wallet before approving or rejecting the request, return to the dapp + // to make a new connection request, and then finally return to the wallet to approve or reject the new connection request. + if ( + isWalletCreateSessionRequest && + Engine.context.ApprovalController.getTotalApprovalCount() > 0 + ) { + // We must manually navigate away from the currently open approval request, otherwise an approval component may be rendered + // with an approval request prop that it cannot handle and cause the wallet to throw an exception. + NavigationService.navigation?.goBack(); + await Engine.context.ApprovalController.clear( + providerErrors.userRejectedRequest({ + data: { + cause: 'rejectAllApprovals', + }, + }), + ); + } + this.bridge.send(payload); }); From 816c44e9c6f98fcb2cee672f57ad6a59cd03aaa1 Mon Sep 17 00:00:00 2001 From: Nick Gambino <35090461+gambinish@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:42:26 -0800 Subject: [PATCH 08/10] chore: Add prewarm method to order fills (#24122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** **Problem** Recent trades in the Perps home screen and asset screen were taking over 3 seconds to show up on the Recent Activity view after placing a trade. This was because the loading state for activity was blocking on both: * WebSocket fills (isFillsLoading) - fast (~100ms when prewarmed) * REST API fills (isRestFillsLoading) - slow (3+ seconds) Additionally, the fills WebSocket channel was not being prewarmed unlike other channels (positions, orders, account), causing the first subscription to have additional latency. Added prewarm support to FillStreamChannel: The fills WebSocket subscription is now prewarmed when entering the Perps environment, ensuring the cache is populated before any component mounts. Enabled fills prewarming in PerpsConnectionManager: Added streamManager.fills.prewarm() call alongside other channel prewarms. Implemented progressive loading: Changed usePerpsHomeData to only wait for WebSocket fills (fast), not REST fills. REST data now loads silently in the background and merges via mergedFills without blocking the initial render. **Trade-offs** The first render shows only WebSocket snapshot data (limited to ~100 recent fills) Full historical data loads in background and appears when ready This is acceptable because the home screen only displays 3 recent trades ## **Changelog** CHANGELOG entry: Improved recent trades loading performance in Perps ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2078 ## **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** https://github.com/user-attachments/assets/9560b30d-fdff-4fdd-b166-e450dcd80682 ## **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. --- > [!NOTE] > Prewarms the fills WebSocket channel and updates home data to show activity immediately from WS, merging REST history in the background. > > - **Streams/Providers**: > - Add `prewarm()` and `cleanupPrewarm()` to `FillStreamChannel` to keep fills WS alive and cached. > - **Connection**: > - Preload fills stream via `streamManager.fills.prewarm()` and include its cleanup in `PerpsConnectionManager`. > - **Home Data Hook**: > - Fetch REST fills in background without a loading flag; merge with WS fills via `mergedFills`. > - Set `isLoading.activity` to `isFillsLoading` only (no longer blocks on REST). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ddf7c7fe5f261dd6419861f818623ec0249cd590. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Perps/hooks/usePerpsHomeData.ts | 12 +++--- .../UI/Perps/providers/PerpsStreamManager.tsx | 38 +++++++++++++++++++ .../Perps/services/PerpsConnectionManager.ts | 2 + 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Perps/hooks/usePerpsHomeData.ts b/app/components/UI/Perps/hooks/usePerpsHomeData.ts index 2fb06392e91..0baaae1957e 100644 --- a/app/components/UI/Perps/hooks/usePerpsHomeData.ts +++ b/app/components/UI/Perps/hooks/usePerpsHomeData.ts @@ -87,17 +87,17 @@ export const usePerpsHomeData = ({ // REST API fills state - WebSocket snapshot only contains recent fills, // so we need to fetch complete history via REST API const [restFills, setRestFills] = useState([]); - const [isRestFillsLoading, setIsRestFillsLoading] = useState(true); - // Fetch historical fills via REST API on mount + // Fetch historical fills via REST API on mount (background, non-blocking) // This ensures we have complete fill history, not just WebSocket snapshot + // Note: We don't track loading state - WebSocket data displays immediately, + // REST fills merge silently in the background via mergedFills useEffect(() => { const fetchFills = async () => { try { const controller = Engine.context.PerpsController; const provider = controller?.getActiveProvider(); if (!provider) { - setIsRestFillsLoading(false); return; } @@ -106,8 +106,6 @@ export const usePerpsHomeData = ({ } catch (error) { // Log error but don't fail - WebSocket fills still work console.error('[usePerpsHomeData] Failed to fetch REST fills:', error); - } finally { - setIsRestFillsLoading(false); } }; fetchFills(); @@ -372,7 +370,9 @@ export const usePerpsHomeData = ({ positions: isPositionsLoading, orders: isOrdersLoading, markets: isMarketsLoading, - activity: isFillsLoading || isRestFillsLoading, + // Only wait for WebSocket fills (fast ~100ms), not REST fills (slow 3s+) + // REST fills merge in background via mergedFills without blocking initial render + activity: isFillsLoading, }, refresh, }; diff --git a/app/components/UI/Perps/providers/PerpsStreamManager.tsx b/app/components/UI/Perps/providers/PerpsStreamManager.tsx index f94776e682b..473854cd5f0 100644 --- a/app/components/UI/Perps/providers/PerpsStreamManager.tsx +++ b/app/components/UI/Perps/providers/PerpsStreamManager.tsx @@ -685,6 +685,8 @@ class PositionStreamChannel extends StreamChannel { // Specific channel for fills class FillStreamChannel extends StreamChannel { + private prewarmUnsubscribe?: () => void; + protected connect() { if (this.wsSubscription) return; @@ -716,6 +718,42 @@ class FillStreamChannel extends StreamChannel { protected getClearedData(): OrderFill[] { return []; } + + /** + * Pre-warm the channel by creating a persistent subscription + * This keeps the WebSocket connection alive and caches fills data continuously + * @returns Cleanup function to call when leaving Perps environment + */ + public prewarm(): () => void { + if (this.prewarmUnsubscribe) { + DevLogger.log('FillStreamChannel: Already pre-warmed'); + return this.prewarmUnsubscribe; + } + + // Create a real subscription with no-op callback to keep connection alive + this.prewarmUnsubscribe = this.subscribe({ + callback: () => { + // No-op callback - just keeps the connection alive for caching + }, + throttleMs: 0, // No throttle for pre-warm + }); + + // Return cleanup function that clears internal state + return () => { + DevLogger.log('FillStreamChannel: Cleaning up prewarm subscription'); + this.cleanupPrewarm(); + }; + } + + /** + * Cleanup pre-warm subscription + */ + public cleanupPrewarm(): void { + if (this.prewarmUnsubscribe) { + this.prewarmUnsubscribe(); + this.prewarmUnsubscribe = undefined; + } + } } // Specific channel for account state diff --git a/app/components/UI/Perps/services/PerpsConnectionManager.ts b/app/components/UI/Perps/services/PerpsConnectionManager.ts index d98663ca6ab..6dff1fbb101 100644 --- a/app/components/UI/Perps/services/PerpsConnectionManager.ts +++ b/app/components/UI/Perps/services/PerpsConnectionManager.ts @@ -869,6 +869,7 @@ class PerpsConnectionManagerClass { const accountCleanup = streamManager.account.prewarm(); const marketDataCleanup = streamManager.marketData.prewarm(); const oiCapCleanup = streamManager.oiCaps.prewarm(); + const fillsCleanup = streamManager.fills.prewarm(); // Portfolio balance updates are now handled by usePerpsPortfolioBalance via usePerpsLiveAccount @@ -882,6 +883,7 @@ class PerpsConnectionManagerClass { accountCleanup, marketDataCleanup, oiCapCleanup, + fillsCleanup, priceCleanup, ); From 2ca63352906d54fe02c1d2ec3e9e437955c1970a Mon Sep 17 00:00:00 2001 From: George Marshall Date: Thu, 18 Dec 2025 13:34:13 -0800 Subject: [PATCH 09/10] fix: sentence case violations 7001-7279 of locales file (#24127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes sentence case violations in lines 7001-7279 of the `locales/languages/en.json` file as part of ongoing content papercut improvements. The changes convert Title Case strings to sentence case following standard capitalization conventions. **What is the reason for the change?** Content consistency and adherence to proper sentence case formatting across the app. **What is the improvement/solution?** Updated ~15 locale keys from Title Case to sentence case in the Rewards, Transaction, Connection, and Explore sections. ## **Changelog** CHANGELOG entry: Fixed sentence case violations in English locale strings lines 7001-7279 ## **Related issues** Fixes: Part of content papercut improvements batch 8 Follows: #23499 (lines 1-1000), #23516 (lines 1001-2000), #23957 (lines 2001-3000), #23994 (lines 3001-4000), #23996 (lines 4001-5000), #24049 (lines 5001-6000), #24056 (lines 6001-7000) Related: #23272 (original comprehensive PR) ## **Manual testing steps** ```gherkin Feature: Locale string display Scenario: user views UI elements with updated locale strings Given the app is running with the updated locale file When user views Rewards settings Then "Rewards settings" should display in sentence case And "Supported networks" should display in sentence case And "Manage card" should display in sentence case When user views transaction details Then "Bridge send" and "Bridge receive" should display in sentence case And "Funded predict account" should display in sentence case And "Connection error" should display in sentence case When user views Explore tab Then "Trending tokens" should display in sentence case And "Popular sites" should display in sentence case ``` ## **Screenshots/Recordings** ### **Before** N/A - Content-only change (Title Case strings) ### **After** N/A - Content-only change (sentence case strings). No visual differences beyond text casing. ## **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)). ## **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. --- ## **Technical Details** ### Changes Made: - **Locale file**: Updated ~15 keys in `locales/languages/en.json` (lines 7001-7279) - **Test files**: No test updates needed (content-only changes) ### Affected Areas: - Rewards settings and ways to earn - MetaMask Card management - Transaction details (Perps/Predict deposits, Bridge operations) - Connection error messages - Explore tab (Trending tokens, Popular sites) ### Validation: - Changes are purely cosmetic (text casing only) - No functional changes to app behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- > [!NOTE] > Converts Title Case to sentence case for Rewards/Trending/Connection/Transaction i18n strings across multiple locales and updates corresponding tests. > > - **i18n (multi-locale)**: > - Normalize casing to sentence case in `locales/languages/*.json`. > - English: update `rewards.settings.title`, `rewards.ways_to_earn.supported_networks`, `rewards.ways_to_earn.card.sheet.cta_label`, several `transaction_details.title.*` and `transaction_details.summary_title.*` (e.g., `bridge_send_loading`, `bridge_receive_loading`), `sdk_connect_v2.show_error.title`/`show_rejection.title`, `trending.trending_tokens`, and `trending.popular_sites`. > - Other locales (`de`, `el`, `es`, `fr`, `hi`, `id`, `ja`, `ko`, `pt`, `ru`, `tl`, `tr`, `vi`, `zh`): update `sdk_connect_v2.show_rejection.title`, `trending.trending_tokens`, and `trending.popular_sites`. > - **Tests**: > - Adjust expected strings to sentence case in `SwapSupportedNetworksSection.test.tsx`, `WaysToEarn.test.tsx`, and `TrendingTokensFullView.test.tsx` (e.g., `Supported networks`, `Manage card`, `Trending tokens`). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 2c290197dbee9250cf473437c4d4b630afa8b29e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Corey Janssen <107953793+coreyjanssen@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../SwapSupportedNetworksSection.test.tsx | 4 ++-- .../WaysToEarn/WaysToEarn.test.tsx | 6 +++--- .../TrendingTokensFullView.test.tsx | 2 +- locales/languages/de.json | 6 +++--- locales/languages/el.json | 6 +++--- locales/languages/en.json | 20 +++++++++---------- locales/languages/es.json | 6 +++--- locales/languages/fr.json | 6 +++--- locales/languages/hi.json | 6 +++--- locales/languages/id.json | 6 +++--- locales/languages/ja.json | 6 +++--- locales/languages/ko.json | 6 +++--- locales/languages/pt.json | 6 +++--- locales/languages/ru.json | 6 +++--- locales/languages/tl.json | 6 +++--- locales/languages/tr.json | 6 +++--- locales/languages/vi.json | 6 +++--- locales/languages/zh.json | 6 +++--- 18 files changed, 58 insertions(+), 58 deletions(-) diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx index fcdc47f80fb..39be956cb7e 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/SwapSupportedNetworksSection.test.tsx @@ -50,7 +50,7 @@ const mockSelectAdditionalNetworksBlacklistFeatureFlag = jest.mock('../../../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const mockStrings: Record = { - 'rewards.ways_to_earn.supported_networks': 'Supported Networks', + 'rewards.ways_to_earn.supported_networks': 'Supported networks', }; return mockStrings[key] || key; }), @@ -139,7 +139,7 @@ describe('SwapSupportedNetworksSection', () => { const { getByText } = render(); // Assert - expect(getByText('Supported Networks')).toBeOnTheScreen(); + expect(getByText('Supported networks')).toBeOnTheScreen(); }); it('renders supported networks', () => { diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index 98a556a00b8..618100cae1b 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -177,7 +177,7 @@ jest.mock('../../../../../../../../locales/i18n', () => ({ 'rewards.ways_to_earn.card.sheet.points': '1 point per $1 spent', 'rewards.ways_to_earn.card.sheet.description': 'Earn points every time you use your MetaMask Card for purchases, plus 1% cash back (3% for Metal cardholders).', - 'rewards.ways_to_earn.card.sheet.cta_label': 'Manage Card', + 'rewards.ways_to_earn.card.sheet.cta_label': 'Manage card', // Deposit MUSD strings 'rewards.ways_to_earn.deposit_musd.title': 'Deposit mUSD', 'rewards.ways_to_earn.deposit_musd.description': @@ -209,7 +209,7 @@ jest.mock('./SwapSupportedNetworksSection', () => ({ return ReactActual.createElement( Text, { testID: 'swap-supported-networks' }, - 'Supported Networks', + 'Supported networks', ); }, })); @@ -1277,7 +1277,7 @@ describe('WaysToEarn', () => { { type: WayToEarnType.CARD, buttonText: 'MetaMask Card', - expectedCTALabel: 'Manage Card', + expectedCTALabel: 'Manage card', enableFlag: () => { mockIsCardSpendEnabled = true; }, diff --git a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx index ce069065a38..64f589211ab 100644 --- a/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx +++ b/app/components/Views/TrendingTokens/TrendingTokensFullView/TrendingTokensFullView.test.tsx @@ -241,7 +241,7 @@ describe('TrendingTokensFullView', () => { false, // Exclude NavigationContainer since we're mocking navigation ); - expect(getByText('Trending Tokens')).toBeOnTheScreen(); + expect(getByText('Trending tokens')).toBeOnTheScreen(); expect(getByTestId('trending-tokens-header-back-button')).toBeOnTheScreen(); }); diff --git a/locales/languages/de.json b/locales/languages/de.json index 6ed4886a3cf..a46210dd141 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -7204,7 +7204,7 @@ "description": "Verbindungsherstellung fehlgeschlagen. Bitte versuchen Sie es erneut." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Alle Netzwerke", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prognosen", "no_results": "No results found", "sites": "Websites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/el.json b/locales/languages/el.json index cfa4bf705d1..22c4d2bae16 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -7204,7 +7204,7 @@ "description": "Απέτυχε η προσπάθεια σύνδεσης. Παρακαλώ δοκιμάστε ξανά." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Όλα τα δίκτυα", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Προβλέψεις", "no_results": "No results found", "sites": "Ιστότοποι", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/en.json b/locales/languages/en.json index 8c2598e25e5..7169b2ed7f0 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -6999,7 +6999,7 @@ } }, "settings": { - "title": "Rewards Settings", + "title": "Rewards settings", "subtitle": "Accounts", "description": "Add multiple accounts to combine your points and unlock rewards faster.", "tab_linked_accounts": "Added ({{count}})", @@ -7047,7 +7047,7 @@ }, "ways_to_earn": { "title": "Ways to earn", - "supported_networks": "Supported Networks", + "supported_networks": "Supported networks", "swap": { "title": "Swap", "description": "8 points per $10", @@ -7104,7 +7104,7 @@ "title": "MetaMask Card", "points": "1 point per $1 spent", "description": "Earn points every time you use your MetaMask Card for purchases, plus 1% cash back (3% for Metal cardholders).", - "cta_label": "Manage Card" + "cta_label": "Manage card" } }, "deposit_musd": { @@ -7179,7 +7179,7 @@ }, "transaction_details": { "title": { - "perps_deposit": "Funded Perps account", + "perps_deposit": "Funded perps account", "predict_claim": "Claimed winnings", "predict_deposit": "Funded Predict account", "predict_withdraw": "Withdrawal", @@ -7198,9 +7198,9 @@ "bridge_approval": "Approve {{approveSymbol}}", "bridge_approval_loading": "Approve", "bridge_send": "Bridge {{sourceSymbol}} from {{sourceChain}}", - "bridge_send_loading": "Bridge Send", + "bridge_send_loading": "Bridge send", "bridge_receive": "Receive {{targetSymbol}} on {{targetChain}}", - "bridge_receive_loading": "Bridge Receive", + "bridge_receive_loading": "Bridge receive", "default": "Transaction", "perps_deposit": "Add funds", "predict_deposit": "Add funds", @@ -7215,11 +7215,11 @@ "description": "Establishing connection with {{dappName}}..." }, "show_error": { - "title": "Connection Error", + "title": "Connection error", "description": "Failed to establish connection. Please try again." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7242,7 +7242,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "All networks", "24h": "24h", @@ -7264,7 +7264,7 @@ "predictions": "Predictions", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/es.json b/locales/languages/es.json index 8db0089932b..206fa2736e6 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -7204,7 +7204,7 @@ "description": "No se pudo establecer la conexión. Inténtalo de nuevo." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Todas las redes", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Predicciones", "no_results": "No results found", "sites": "Sitios", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/fr.json b/locales/languages/fr.json index d9f71453efd..e4451d60fd7 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -7204,7 +7204,7 @@ "description": "La connexion a échoué. Veuillez réessayer." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Jetons", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tous les réseaux", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prédictions", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/hi.json b/locales/languages/hi.json index a86cdaca185..4fbb2669b80 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -7204,7 +7204,7 @@ "description": "कनेक्शन स्थापित करना नहीं हो पाया। कृपया फिर से प्रयास करें।" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "टोकन", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "सभी नेटवर्क", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "प्रेडिक्शंस", "no_results": "No results found", "sites": "साइट्स", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/id.json b/locales/languages/id.json index 0e71a65d133..ed88f9395d5 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -7204,7 +7204,7 @@ "description": "Gagal membuat koneksi. Coba lagi." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Semua jaringan", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Prediksi", "no_results": "No results found", "sites": "Situs", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ja.json b/locales/languages/ja.json index e82a80291d2..e584c3b4535 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -7204,7 +7204,7 @@ "description": "接続の確立に失敗しました。もう一度お試しください。" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "トークン", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "すべてのネットワーク", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "予測", "no_results": "No results found", "sites": "サイト", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ko.json b/locales/languages/ko.json index e61a6376775..350bf123982 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -7204,7 +7204,7 @@ "description": "연결하는 데 실패했습니다. 다시 시도하세요." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "토큰", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "모든 네트워크", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "예측", "no_results": "No results found", "sites": "사이트", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/pt.json b/locales/languages/pt.json index 2624166b113..94fca005c3e 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -7204,7 +7204,7 @@ "description": "Não foi possível estabelecer a conexão. Tente novamente." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Tokens", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Todas as redes", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Previsões", "no_results": "No results found", "sites": "Sites", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/ru.json b/locales/languages/ru.json index 06a3e8c5810..37edee8c2a7 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -7204,7 +7204,7 @@ "description": "Не удалось установить соединение. Попробуйте ещё раз." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Токены", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Все сети", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Прогнозы", "no_results": "No results found", "sites": "Сайты", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/tl.json b/locales/languages/tl.json index f26529913a7..41213652745 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -7204,7 +7204,7 @@ "description": "Nabigong maitatag ang koneksyon. Pakisubukan ulit." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Mga Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Lahat ng network", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Mga hula", "no_results": "No results found", "sites": "Mga Site", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/tr.json b/locales/languages/tr.json index a40cd2df30f..00a5cf14966 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -7204,7 +7204,7 @@ "description": "Bağlantı kurulamadı. Lütfen tekrar deneyin." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token'lar", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tüm ağlar", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Tahminler", "no_results": "No results found", "sites": "Siteler", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/vi.json b/locales/languages/vi.json index 272e1f1e9f5..337f0c1a632 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -7204,7 +7204,7 @@ "description": "Không thể thiết lập kết nối. Vui lòng thử lại." }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "Token", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "Tất cả mạng", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "Dự đoán", "no_results": "No results found", "sites": "Trang web", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", diff --git a/locales/languages/zh.json b/locales/languages/zh.json index 035a740086d..c139592c73e 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -7204,7 +7204,7 @@ "description": "建立连接失败。请重试。" }, "show_rejection": { - "title": "Approval Rejected", + "title": "Approval rejected", "description": "User rejected the request." }, "show_return_to_app": { @@ -7227,7 +7227,7 @@ "title": "Explore", "view_all": "View all", "tokens": "代币", - "trending_tokens": "Trending Tokens", + "trending_tokens": "Trending tokens", "price_change": "Price change", "all_networks": "所有网络", "24h": "24h", @@ -7249,7 +7249,7 @@ "predictions": "预测", "no_results": "No results found", "sites": "网站", - "popular_sites": "Popular Sites", + "popular_sites": "Popular sites", "search_sites": "Search sites", "enable_basic_functionality": "Enable basic functionality", "basic_functionality_disabled_title": "Explore is not available", From 2e4ef855b828d9d6b0b1fab62ef1edeed6f8df5d Mon Sep 17 00:00:00 2001 From: asalsys Date: Thu, 18 Dec 2025 17:15:41 -0500 Subject: [PATCH 10/10] chore: migrate off of useFeatureFlag (#24125) ## **Description** This PR migrates feature flag checks from the custom `useFeatureFlag` hook to Redux selectors, aligning with the codebase's feature flag architecture guidelines. ### Reason for change: - The existing `useFeatureFlag` hook was creating an inconsistent pattern for accessing feature flags - Redux selectors provide better memoization, testability, and integration with the existing state management architecture - Centralizes feature flag access patterns for better maintainability ### Solution: - Added new Redux selectors for OTA Updates, Full Page Account List, and Rewards feature flags - Each selector includes both a "raw" version (direct flag value) and a combined version that respects the `basicFunctionalityEnabled` setting - Updated all consuming components to use `useSelector` with the new selectors - Added comprehensive test coverage for all new selectors - Removed the deprecated `useFeatureFlag` hook ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** Feature: Feature Flag Selectors Scenario: OTA Updates flag respects basic functionality setting Given the app has basic functionality enabled And the remote feature flag for OTA updates is enabled When the app checks if OTA updates are enabled Then the selector returns true Scenario: Full Page Account List flag is disabled when basic functionality is off Given the app has basic functionality disabled And the remote feature flag for full page account list is enabled When the AccountSelector component renders Then it uses the non-full-page account list UI Scenario: Rewards flags work correctly Given the app has basic functionality enabled And the rewards feature flags are enabled remotely When the WaysToEarn component checks mUSD holding flag Then the appropriate rewards options are displayed## **Screenshots/Recordings** ### **Before** N/A - Internal refactoring with no visual changes ### **After** N/A - Internal refactoring with no visual changes ## **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. --- .../WaysToEarn/WaysToEarn.test.tsx | 23 +- .../OverviewTab/WaysToEarn/WaysToEarn.tsx | 9 +- .../AccountSelector/AccountSelector.test.tsx | 38 ++-- .../Views/AccountSelector/AccountSelector.tsx | 6 +- .../FeatureFlagOverride.test.tsx | 2 +- .../FeatureFlagOverride.tsx | 2 +- app/components/hooks/useFeatureFlag.test.ts | 158 ------------- app/components/hooks/useFeatureFlag.ts | 21 -- app/components/hooks/useOTAUpdates.test.ts | 50 ++--- app/components/hooks/useOTAUpdates.ts | 5 +- app/constants/featureFlags.ts | 11 + .../fullPageAccountList/index.test.ts | 128 +++++++++++ .../fullPageAccountList/index.ts | 47 ++++ .../otaUpdates/index.test.ts | 116 ++++++++++ .../featureFlagController/otaUpdates/index.ts | 47 ++++ .../featureFlagController/rewards/index.ts | 10 + .../rewards/rewardsEnabled.test.ts | 207 ++++++++++++++++++ .../rewards/rewardsEnabled.ts | 85 +++++++ 18 files changed, 713 insertions(+), 252 deletions(-) delete mode 100644 app/components/hooks/useFeatureFlag.test.ts delete mode 100644 app/components/hooks/useFeatureFlag.ts create mode 100644 app/constants/featureFlags.ts create mode 100644 app/selectors/featureFlagController/fullPageAccountList/index.test.ts create mode 100644 app/selectors/featureFlagController/fullPageAccountList/index.ts create mode 100644 app/selectors/featureFlagController/otaUpdates/index.test.ts create mode 100644 app/selectors/featureFlagController/otaUpdates/index.ts create mode 100644 app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts create mode 100644 app/selectors/featureFlagController/rewards/rewardsEnabled.ts diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx index 618100cae1b..0a997e37af5 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.test.tsx @@ -11,6 +11,7 @@ import { selectRewardsCardSpendFeatureFlags, selectRewardsMusdDepositEnabledFlag, } from '../../../../../../../selectors/featureFlagController/rewards'; +import { selectMusdHoldingEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; import { MetaMetricsEvents } from '../../../../../../hooks/useMetrics'; import { RewardsMetricsButtons } from '../../../../utils'; @@ -80,20 +81,13 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -// Mock useFeatureFlag hook -jest.mock('../../../../../../../components/hooks/useFeatureFlag', () => ({ - useFeatureFlag: jest.fn((key: string) => { - if (key === 'rewardsEnableMusdHolding') { - return mockIsMusdHoldingEnabled; - } - return false; +// Mock selectMusdHoldingEnabledFlag selector +jest.mock( + '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled', + () => ({ + selectMusdHoldingEnabledFlag: jest.fn(), }), - FeatureFlagNames: { - rewardsEnabled: 'rewardsEnabled', - otaUpdatesEnabled: 'otaUpdatesEnabled', - rewardsEnableMusdHolding: 'rewardsEnableMusdHolding', - }, -})); +); // Mock useMetrics hook jest.mock('../../../../../../hooks/useMetrics', () => ({ @@ -283,6 +277,9 @@ describe('WaysToEarn', () => { if (selector === selectRewardsMusdDepositEnabledFlag) { return mockIsMusdDepositEnabled; } + if (selector === selectMusdHoldingEnabledFlag) { + return mockIsMusdHoldingEnabled; + } return undefined; }); diff --git a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx index 5c064f9cfa1..0a54051fbcb 100644 --- a/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx +++ b/app/components/UI/Rewards/components/Tabs/OverviewTab/WaysToEarn/WaysToEarn.tsx @@ -33,11 +33,8 @@ import { selectRewardsCardSpendFeatureFlags, selectRewardsMusdDepositEnabledFlag, } from '../../../../../../../selectors/featureFlagController/rewards'; +import { selectMusdHoldingEnabledFlag } from '../../../../../../../selectors/featureFlagController/rewards/rewardsEnabled'; import { selectPredictEnabledFlag } from '../../../../../Predict/selectors/featureFlags'; -import { - useFeatureFlag, - FeatureFlagNames, -} from '../../../../../../hooks/useFeatureFlag'; import { PredictEventValues } from '../../../../../Predict/constants/eventNames'; import { MetaMetricsEvents, @@ -263,9 +260,7 @@ export const WaysToEarn = () => { const isCardSpendEnabled = useSelector(selectRewardsCardSpendFeatureFlags); const isPredictEnabled = useSelector(selectPredictEnabledFlag); const isMusdDepositEnabled = useSelector(selectRewardsMusdDepositEnabledFlag); - const isMusdHoldingEnabled = useFeatureFlag( - FeatureFlagNames.rewardsEnableMusdHolding, - ); + const isMusdHoldingEnabled = useSelector(selectMusdHoldingEnabledFlag); const { trackEvent, createEventBuilder } = useMetrics(); // Use the swap/bridge navigation hook diff --git a/app/components/Views/AccountSelector/AccountSelector.test.tsx b/app/components/Views/AccountSelector/AccountSelector.test.tsx index a431b6545e3..cad23997691 100644 --- a/app/components/Views/AccountSelector/AccountSelector.test.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.test.tsx @@ -20,14 +20,14 @@ import { internalSolanaAccount1, } from '../../../util/test/accountsControllerTestUtils'; -jest.mock('../../hooks/useFeatureFlag', () => ({ - useFeatureFlag: jest.fn(() => false), // Default to BottomSheet version for tests - FeatureFlagNames: { - rewardsEnabled: 'rewardsEnabled', - otaUpdatesEnabled: 'otaUpdatesEnabled', - fullPageAccountList: 'fullPageAccountList', - }, -})); +const mockSelectFullPageAccountListEnabledFlag = jest.fn(() => false); +jest.mock( + '../../../selectors/featureFlagController/fullPageAccountList', + () => ({ + selectFullPageAccountListEnabledFlag: () => + mockSelectFullPageAccountListEnabledFlag(), + }), +); const mockAvatarAccountType = 'Maskicon' as const; @@ -670,17 +670,13 @@ describe('AccountSelector', () => { }); describe('Feature Flag: Full-Page Account List', () => { - let mockUseFeatureFlag: jest.Mock; - beforeEach(() => { jest.clearAllMocks(); - mockUseFeatureFlag = jest.requireMock( - '../../hooks/useFeatureFlag', - ).useFeatureFlag; + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); }); it('renders BottomSheet when feature flag is disabled', () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); renderScreen( AccountSelectorWrapper, @@ -702,7 +698,7 @@ describe('AccountSelector', () => { }); it('renders full-page modal when feature flag is enabled', () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); renderScreen( AccountSelectorWrapper, @@ -725,7 +721,7 @@ describe('AccountSelector', () => { it('renders add button in both modes', () => { // Arrange: BottomSheet mode - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); // Act: Render in BottomSheet mode const { unmount } = renderScreen( @@ -750,7 +746,7 @@ describe('AccountSelector', () => { // Arrange: Full-page mode jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); // Act: Render in full-page mode renderScreen( @@ -777,7 +773,7 @@ describe('AccountSelector', () => { it('switches between multichain screens in full-page mode', () => { // Arrange jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); renderScreen( @@ -806,7 +802,7 @@ describe('AccountSelector', () => { it('closes BottomSheet when account is selected with feature flag disabled', async () => { // Arrange - mockUseFeatureFlag.mockReturnValue(false); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(false); const { getAllByTestId } = renderScreen( AccountSelectorWrapper, @@ -840,7 +836,7 @@ describe('AccountSelector', () => { it('renders SheetHeader with title in full-page mode', () => { // Arrange - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); renderScreen( AccountSelectorWrapper, @@ -864,7 +860,7 @@ describe('AccountSelector', () => { it('closes full-page modal when account is selected with feature flag enabled', async () => { // Arrange jest.useRealTimers(); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectFullPageAccountListEnabledFlag.mockReturnValue(true); // Mock the useNavigation hook to prevent navigation warnings const mockGoBack = jest.fn(); diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx index f78ffd33468..31684e544b7 100644 --- a/app/components/Views/AccountSelector/AccountSelector.tsx +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -36,7 +36,7 @@ import BottomSheet, { import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; import Engine from '../../../core/Engine'; -import { useFeatureFlag, FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { selectFullPageAccountListEnabledFlag } from '../../../selectors/featureFlagController/fullPageAccountList'; import { store } from '../../../store'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { strings } from '../../../../locales/i18n'; @@ -95,8 +95,8 @@ const AccountSelector = ({ route }: AccountSelectorProps) => { const routeParams = useMemo(() => route?.params, [route?.params]); // Feature flag for full-page account list - const isFullPageAccountList = useFeatureFlag( - FeatureFlagNames.fullPageAccountList, + const isFullPageAccountList = useSelector( + selectFullPageAccountListEnabledFlag, ); const sheetRef = useRef(null); diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx index 8ece0d1a650..88668afe83e 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.test.tsx @@ -10,7 +10,7 @@ import { FeatureFlagInfo, isMinimumRequiredVersionSupported, } from '../../../util/feature-flags'; -import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; // Mock all dependencies jest.mock('@react-navigation/native', () => ({ diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx index ff31fda0f3e..67579950a46 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx @@ -23,7 +23,7 @@ import { } from '../../../util/feature-flags'; import { useFeatureFlagOverride } from '../../../contexts/FeatureFlagOverrideContext'; import { useFeatureFlagStats } from '../../../hooks/useFeatureFlagStats'; -import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; +import { FeatureFlagNames } from '../../../constants/featureFlags'; interface FeatureFlagRowProps { flag: FeatureFlagInfo; diff --git a/app/components/hooks/useFeatureFlag.test.ts b/app/components/hooks/useFeatureFlag.test.ts deleted file mode 100644 index 49195558702..00000000000 --- a/app/components/hooks/useFeatureFlag.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; -import { useFeatureFlagOverride } from '../../contexts/FeatureFlagOverrideContext'; -import { selectBasicFunctionalityEnabled } from '../../selectors/settings'; - -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), -})); - -jest.mock('../../contexts/FeatureFlagOverrideContext', () => ({ - useFeatureFlagOverride: jest.fn(), -})); - -// Mock the useFeatureFlag module with mocked FeatureFlagNames enum -jest.mock('./useFeatureFlag', () => { - const actual = jest.requireActual('./useFeatureFlag'); - return { - ...actual, - FeatureFlagNames: { - mockedFlagEnabled: 'mockedFlagEnabled', - } as typeof actual.FeatureFlagNames, - }; -}); - -import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; - -// Type for the mocked FeatureFlagNames enum -type MockedFeatureFlagNames = typeof FeatureFlagNames & { - mockedFlagEnabled: 'mockedFlagEnabled'; -}; - -// Create a typed reference to the mocked flag -const MOCKED_FLAG = (FeatureFlagNames as MockedFeatureFlagNames) - .mockedFlagEnabled as FeatureFlagNames; - -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFeatureFlagOverride = - useFeatureFlagOverride as jest.MockedFunction; - -describe('useFeatureFlag', () => { - let mockGetFeatureFlag: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - - mockGetFeatureFlag = jest.fn(); - mockUseFeatureFlagOverride.mockReturnValue({ - getFeatureFlag: mockGetFeatureFlag, - } as unknown as ReturnType); - }); - - describe('when basic functionality is disabled', () => { - it('returns false without calling getFeatureFlag', () => { - mockUseSelector.mockReturnValue(false); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockUseSelector).toHaveBeenCalledWith( - selectBasicFunctionalityEnabled, - ); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - }); - - describe('when basic functionality is enabled', () => { - beforeEach(() => { - mockUseSelector.mockReturnValue(true); - }); - - it('returns true when getFeatureFlag returns true', () => { - mockGetFeatureFlag.mockReturnValue(true); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(true); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('returns false when getFeatureFlag returns false', () => { - mockGetFeatureFlag.mockReturnValue(false); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('returns undefined when getFeatureFlag returns undefined', () => { - mockGetFeatureFlag.mockReturnValue(undefined); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBeUndefined(); - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - expect(mockGetFeatureFlag).toHaveBeenCalledTimes(1); - }); - - it('calls getFeatureFlag with the correct feature flag key', () => { - mockGetFeatureFlag.mockReturnValue(true); - - renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(mockGetFeatureFlag).toHaveBeenCalledWith(MOCKED_FLAG); - }); - - it('calls useSelector with selectBasicFunctionalityEnabled selector', () => { - mockGetFeatureFlag.mockReturnValue(true); - - renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(mockUseSelector).toHaveBeenCalledWith( - selectBasicFunctionalityEnabled, - ); - expect(mockUseSelector).toHaveBeenCalledTimes(1); - }); - }); - - describe('edge cases', () => { - it('returns false when basic functionality is null', () => { - mockUseSelector.mockReturnValue(null as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is undefined', () => { - mockUseSelector.mockReturnValue(undefined as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is 0', () => { - mockUseSelector.mockReturnValue(0 as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - - it('returns false when basic functionality is empty string', () => { - mockUseSelector.mockReturnValue('' as unknown as boolean); - - const { result } = renderHook(() => useFeatureFlag(MOCKED_FLAG)); - - expect(result.current).toBe(false); - expect(mockGetFeatureFlag).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/app/components/hooks/useFeatureFlag.ts b/app/components/hooks/useFeatureFlag.ts deleted file mode 100644 index 9d3be7bf250..00000000000 --- a/app/components/hooks/useFeatureFlag.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useSelector } from 'react-redux'; -import { useFeatureFlagOverride } from '../../contexts/FeatureFlagOverrideContext'; -import { selectBasicFunctionalityEnabled } from '../../selectors/settings'; - -export enum FeatureFlagNames { - rewardsEnabled = 'rewardsEnabled', - otaUpdatesEnabled = 'otaUpdatesEnabled', - rewardsEnableMusdHolding = 'rewardsEnableMusdHolding', - fullPageAccountList = 'fullPageAccountList', -} - -export const useFeatureFlag = (key: FeatureFlagNames) => { - const { getFeatureFlag } = useFeatureFlagOverride(); - const isBasicFunctionalityEnabled = useSelector( - selectBasicFunctionalityEnabled, - ); - if (!isBasicFunctionalityEnabled) { - return false; - } - return getFeatureFlag(key); -}; diff --git a/app/components/hooks/useOTAUpdates.test.ts b/app/components/hooks/useOTAUpdates.test.ts index 56e177e8a8d..df54aebdacf 100644 --- a/app/components/hooks/useOTAUpdates.test.ts +++ b/app/components/hooks/useOTAUpdates.test.ts @@ -6,7 +6,6 @@ import { reloadAsync, UpdateCheckResultNotAvailableReason, } from 'expo-updates'; -import { useFeatureFlag } from './useFeatureFlag'; import { useOTAUpdates } from './useOTAUpdates'; import Logger from '../../util/Logger'; @@ -16,13 +15,15 @@ jest.mock('expo-updates', () => ({ reloadAsync: jest.fn(), })); -jest.mock('./useFeatureFlag', () => { - const actual = jest.requireActual('./useFeatureFlag'); - return { - ...actual, - useFeatureFlag: jest.fn(), - }; -}); +const mockSelectOtaUpdatesEnabledFlag = jest.fn(); +jest.mock('../../selectors/featureFlagController/otaUpdates', () => ({ + selectOtaUpdatesEnabledFlag: () => mockSelectOtaUpdatesEnabledFlag(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: (selector: () => unknown) => selector(), +})); jest.mock('../../util/Logger', () => ({ log: jest.fn(), @@ -42,9 +43,6 @@ const mockManifest = { }; describe('useOTAUpdates', () => { - const mockUseFeatureFlag = useFeatureFlag as jest.MockedFunction< - typeof useFeatureFlag - >; const mockCheckForUpdateAsync = checkForUpdateAsync as jest.MockedFunction< typeof checkForUpdateAsync >; @@ -60,12 +58,12 @@ describe('useOTAUpdates', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); (global as unknown as { __DEV__: boolean }).__DEV__ = false; }); it('returns isCheckingUpdates as false when feature flag is disabled', async () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); const { result } = renderHook(() => useOTAUpdates()); @@ -77,7 +75,7 @@ describe('useOTAUpdates', () => { it('skips update check in development mode', async () => { (global as unknown as { __DEV__: boolean }).__DEV__ = true; - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); const { result } = renderHook(() => useOTAUpdates()); @@ -88,7 +86,7 @@ describe('useOTAUpdates', () => { }); it('checks for updates when feature flag is enabled', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -104,7 +102,7 @@ describe('useOTAUpdates', () => { }); it('sets isCheckingUpdates to false when no update is available', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -121,7 +119,7 @@ describe('useOTAUpdates', () => { }); it('fetches and reloads when a new update is available', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -145,7 +143,7 @@ describe('useOTAUpdates', () => { }); it('sets isCheckingUpdates to false when update is fetched but not new', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -168,7 +166,7 @@ describe('useOTAUpdates', () => { it('logs error and sets isCheckingUpdates to false when check fails', async () => { const mockError = new Error('Update check failed'); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockRejectedValue(mockError); const { result } = renderHook(() => useOTAUpdates()); @@ -184,7 +182,7 @@ describe('useOTAUpdates', () => { it('does not block app if reload fails', async () => { const mockError = new Error('Reload failed'); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, @@ -210,7 +208,7 @@ describe('useOTAUpdates', () => { }); it('checks for updates when feature flag changes from disabled to enabled', async () => { - mockUseFeatureFlag.mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(false); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -225,7 +223,7 @@ describe('useOTAUpdates', () => { expect(mockCheckForUpdateAsync).not.toHaveBeenCalled(); }); - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); rerender(); await waitFor(() => { @@ -234,7 +232,9 @@ describe('useOTAUpdates', () => { }); it('does not check for updates again when feature flag changes from enabled to disabled', async () => { - mockUseFeatureFlag.mockReturnValueOnce(true).mockReturnValue(false); + mockSelectOtaUpdatesEnabledFlag + .mockReturnValueOnce(true) + .mockReturnValue(false); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: false, isRollBackToEmbedded: false, @@ -256,7 +256,7 @@ describe('useOTAUpdates', () => { }); it('starts with isCheckingUpdates as true', () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); const { result } = renderHook(() => useOTAUpdates()); @@ -264,7 +264,7 @@ describe('useOTAUpdates', () => { }); it('calls update check, fetch, and reload in order', async () => { - mockUseFeatureFlag.mockReturnValue(true); + mockSelectOtaUpdatesEnabledFlag.mockReturnValue(true); mockCheckForUpdateAsync.mockResolvedValue({ isAvailable: true, manifest: mockManifest, diff --git a/app/components/hooks/useOTAUpdates.ts b/app/components/hooks/useOTAUpdates.ts index a2b34db6873..42117f715dd 100644 --- a/app/components/hooks/useOTAUpdates.ts +++ b/app/components/hooks/useOTAUpdates.ts @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { checkForUpdateAsync, fetchUpdateAsync, reloadAsync, } from 'expo-updates'; import Logger from '../../util/Logger'; -import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; +import { selectOtaUpdatesEnabledFlag } from '../../selectors/featureFlagController/otaUpdates'; /** * Hook to manage OTA updates based on feature flag @@ -14,7 +15,7 @@ import { useFeatureFlag, FeatureFlagNames } from './useFeatureFlag'; * Returns isCheckingUpdates to gate rendering until check is complete */ export const useOTAUpdates = () => { - const otaUpdatesEnabled = useFeatureFlag(FeatureFlagNames.otaUpdatesEnabled); + const otaUpdatesEnabled = useSelector(selectOtaUpdatesEnabledFlag); const [isCheckingUpdates, setIsCheckingUpdates] = useState(true); useEffect(() => { diff --git a/app/constants/featureFlags.ts b/app/constants/featureFlags.ts new file mode 100644 index 00000000000..f184f351d1b --- /dev/null +++ b/app/constants/featureFlags.ts @@ -0,0 +1,11 @@ +/** + * Feature flag names that can be overridden in development tools. + * These correspond to remote feature flags that have selector implementations + * in app/selectors/featureFlagController/ + */ +export enum FeatureFlagNames { + rewardsEnabled = 'rewardsEnabled', + otaUpdatesEnabled = 'otaUpdatesEnabled', + rewardsEnableMusdHolding = 'rewardsEnableMusdHolding', + fullPageAccountList = 'fullPageAccountList', +} diff --git a/app/selectors/featureFlagController/fullPageAccountList/index.test.ts b/app/selectors/featureFlagController/fullPageAccountList/index.test.ts new file mode 100644 index 00000000000..6552c16f48e --- /dev/null +++ b/app/selectors/featureFlagController/fullPageAccountList/index.test.ts @@ -0,0 +1,128 @@ +import { + selectFullPageAccountListEnabledRawFlag, + selectFullPageAccountListEnabledFlag, + FULL_PAGE_ACCOUNT_LIST_FLAG_NAME, +} from '.'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('Full Page Account List Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectFullPageAccountListEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + [FULL_PAGE_ACCOUNT_LIST_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectFullPageAccountListEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectFullPageAccountListEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + true, + true, + ); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + true, + false, + ); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + false, + true, + ); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectFullPageAccountListEnabledFlag.resultFunc( + false, + false, + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/fullPageAccountList/index.ts b/app/selectors/featureFlagController/fullPageAccountList/index.ts new file mode 100644 index 00000000000..162f9f69bd1 --- /dev/null +++ b/app/selectors/featureFlagController/fullPageAccountList/index.ts @@ -0,0 +1,47 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED = false; +export const FULL_PAGE_ACCOUNT_LIST_FLAG_NAME = 'fullPageAccountList'; + +/** + * Selector for the raw full page account list remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectFullPageAccountListEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, FULL_PAGE_ACCOUNT_LIST_FLAG_NAME)) { + return DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + FULL_PAGE_ACCOUNT_LIST_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_FULL_PAGE_ACCOUNT_LIST_ENABLED + ); + }, +); + +/** + * Selector for the full page account list enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectFullPageAccountListEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectFullPageAccountListEnabledRawFlag, + (isBasicFunctionalityEnabled, fullPageAccountListEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return fullPageAccountListEnabledRawFlag; + }, +); diff --git a/app/selectors/featureFlagController/otaUpdates/index.test.ts b/app/selectors/featureFlagController/otaUpdates/index.test.ts new file mode 100644 index 00000000000..bac3b3e31ea --- /dev/null +++ b/app/selectors/featureFlagController/otaUpdates/index.test.ts @@ -0,0 +1,116 @@ +import { + selectOtaUpdatesEnabledRawFlag, + selectOtaUpdatesEnabledFlag, + OTA_UPDATES_FLAG_NAME, +} from '.'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('OTA Updates Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectOtaUpdatesEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + [OTA_UPDATES_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectOtaUpdatesEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectOtaUpdatesEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectOtaUpdatesEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/otaUpdates/index.ts b/app/selectors/featureFlagController/otaUpdates/index.ts new file mode 100644 index 00000000000..619fc97ac1d --- /dev/null +++ b/app/selectors/featureFlagController/otaUpdates/index.ts @@ -0,0 +1,47 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_OTA_UPDATES_ENABLED = false; +export const OTA_UPDATES_FLAG_NAME = 'otaUpdatesEnabled'; + +/** + * Selector for the raw OTA updates enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectOtaUpdatesEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, OTA_UPDATES_FLAG_NAME)) { + return DEFAULT_OTA_UPDATES_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + OTA_UPDATES_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_OTA_UPDATES_ENABLED + ); + }, +); + +/** + * Selector for the OTA updates enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectOtaUpdatesEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectOtaUpdatesEnabledRawFlag, + (isBasicFunctionalityEnabled, otaUpdatesEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return otaUpdatesEnabledRawFlag; + }, +); diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts index 275ae27c7cf..e483f109a1b 100644 --- a/app/selectors/featureFlagController/rewards/index.ts +++ b/app/selectors/featureFlagController/rewards/index.ts @@ -6,6 +6,16 @@ import { VersionGatedFeatureFlag, } from '../../../util/remoteFeatureFlag'; +// Re-export selectors from rewardsEnabled.ts +export { + selectRewardsEnabledFlag, + selectRewardsEnabledRawFlag, + selectMusdHoldingEnabledFlag, + selectMusdHoldingEnabledRawFlag, + REWARDS_ENABLED_FLAG_NAME, + MUSD_HOLDING_FLAG_NAME, +} from './rewardsEnabled'; + const DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED = false; const DEFAULT_CARD_SPEND_ENABLED = false; const DEFAULT_MUSD_DEPOSIT_ENABLED = false; diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts new file mode 100644 index 00000000000..1cae6ec674a --- /dev/null +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.test.ts @@ -0,0 +1,207 @@ +import { + selectRewardsEnabledRawFlag, + selectRewardsEnabledFlag, + selectMusdHoldingEnabledRawFlag, + selectMusdHoldingEnabledFlag, + REWARDS_ENABLED_FLAG_NAME, + MUSD_HOLDING_FLAG_NAME, +} from './rewardsEnabled'; +// eslint-disable-next-line import/no-namespace +import * as remoteFeatureFlagModule from '../../../util/remoteFeatureFlag'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('1.0.0'), +})); + +describe('Rewards Enabled Feature Flag Selectors', () => { + let mockHasMinimumRequiredVersion: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockHasMinimumRequiredVersion = jest.spyOn( + remoteFeatureFlagModule, + 'hasMinimumRequiredVersion', + ); + mockHasMinimumRequiredVersion.mockReturnValue(true); + }); + + afterEach(() => { + mockHasMinimumRequiredVersion?.mockRestore(); + }); + + describe('selectRewardsEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + [REWARDS_ENABLED_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectRewardsEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectRewardsEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectRewardsEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectRewardsEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectRewardsEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectRewardsEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); + + describe('selectMusdHoldingEnabledRawFlag', () => { + it('returns true when remote flag is valid and enabled', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is valid but disabled', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when version check fails', () => { + mockHasMinimumRequiredVersion.mockReturnValue(false); + + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: true, + minimumVersion: '99.0.0', + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote flag is invalid', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + [MUSD_HOLDING_FLAG_NAME]: { + enabled: 'invalid', + minimumVersion: 123, + }, + }); + + expect(result).toBe(false); + }); + + it('returns false when remote feature flags are empty', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({}); + + expect(result).toBe(false); + }); + + it('returns false when flag property is missing', () => { + const result = selectMusdHoldingEnabledRawFlag.resultFunc({ + someOtherFlag: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('selectMusdHoldingEnabledFlag', () => { + it('returns true when basic functionality is enabled and raw flag is true', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(true, true); + + expect(result).toBe(true); + }); + + it('returns false when basic functionality is enabled and raw flag is false', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(true, false); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled even if raw flag is true', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(false, true); + + expect(result).toBe(false); + }); + + it('returns false when basic functionality is disabled and raw flag is false', () => { + const result = selectMusdHoldingEnabledFlag.resultFunc(false, false); + + expect(result).toBe(false); + }); + }); +}); diff --git a/app/selectors/featureFlagController/rewards/rewardsEnabled.ts b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts new file mode 100644 index 00000000000..2477539ef5c --- /dev/null +++ b/app/selectors/featureFlagController/rewards/rewardsEnabled.ts @@ -0,0 +1,85 @@ +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '..'; +import { hasProperty } from '@metamask/utils'; +import { + validatedVersionGatedFeatureFlag, + VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; +import { selectBasicFunctionalityEnabled } from '../../settings'; + +const DEFAULT_REWARDS_ENABLED = false; +export const REWARDS_ENABLED_FLAG_NAME = 'rewardsEnabled'; + +export const MUSD_HOLDING_FLAG_NAME = 'rewardsEnableMusdHolding'; +const DEFAULT_MUSD_HOLDING_ENABLED = false; + +/** + * Selector for the raw rewards enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectRewardsEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, REWARDS_ENABLED_FLAG_NAME)) { + return DEFAULT_REWARDS_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + REWARDS_ENABLED_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED + ); + }, +); + +/** + * Selector for the rewards enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectRewardsEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectRewardsEnabledRawFlag, + (isBasicFunctionalityEnabled, rewardsEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return rewardsEnabledRawFlag; + }, +); + +/** + * Selector for the raw mUSD holding enabled remote flag value. + * Returns the flag value without considering basic functionality. + */ +export const selectMusdHoldingEnabledRawFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + if (!hasProperty(remoteFeatureFlags, MUSD_HOLDING_FLAG_NAME)) { + return DEFAULT_MUSD_HOLDING_ENABLED; + } + const remoteFlag = remoteFeatureFlags[ + MUSD_HOLDING_FLAG_NAME + ] as unknown as VersionGatedFeatureFlag; + + return ( + validatedVersionGatedFeatureFlag(remoteFlag) ?? + DEFAULT_MUSD_HOLDING_ENABLED + ); + }, +); + +/** + * Selector for the mUSD holding enabled flag. + * Returns false if basic functionality is disabled, otherwise returns the remote flag value. + */ +export const selectMusdHoldingEnabledFlag = createSelector( + selectBasicFunctionalityEnabled, + selectMusdHoldingEnabledRawFlag, + (isBasicFunctionalityEnabled, musdHoldingEnabledRawFlag) => { + if (!isBasicFunctionalityEnabled) { + return false; + } + return musdHoldingEnabledRawFlag; + }, +);