From 8ad0072e03ea600fc8e8df367f5966361498935a Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:39:30 +0100 Subject: [PATCH 01/15] chore: bump {profile-sync,notification-services}-controller (#12615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We want to ensure that mobile is using the latest versions of all controllers. - Bump `@metamask/notification-services-controller` from 0.14.0 to 0.15.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/notification-services-controller/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/profile-sync-controller` from 2.0.0 to 3.0.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/profile-sync-controller/CHANGELOG.md)) - The `UserStorageController` messenger must now allow the actions `NetworkController:getState`, `NetworkController:addNetwork`, `NetworkController:removeNetwork`, and `NetworkController:updateNetwork` - The `UserStorageController` messenger must now allow the event `NetworkController:networkRemoved` ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12514,https://github.com/MetaMask/metamask-mobile/issues/12616 ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/core/Engine/Engine.ts | 5 +++++ package.json | 4 ++-- yarn.lock | 28 ++++++++++++++-------------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 4e21df41a30..45ecd83150b 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -1042,12 +1042,17 @@ export class Engine { 'NotificationServicesController:selectIsNotificationServicesEnabled', AccountsControllerListAccountsAction, AccountsControllerUpdateAccountMetadataAction, + 'NetworkController:getState', + 'NetworkController:addNetwork', + 'NetworkController:removeNetwork', + 'NetworkController:updateNetwork', ], allowedEvents: [ 'KeyringController:unlock', 'KeyringController:lock', AccountsControllerAccountAddedEvent, AccountsControllerAccountRenamedEvent, + 'NetworkController:networkRemoved', ], }), nativeScryptCrypto: scrypt, diff --git a/package.json b/package.json index 36e526e6b4d..b11e4906799 100644 --- a/package.json +++ b/package.json @@ -176,13 +176,13 @@ "@metamask/logging-controller": "^6.0.1", "@metamask/message-signing-snap": "^0.3.3", "@metamask/network-controller": "^22.1.0", - "@metamask/notification-services-controller": "^0.14.0", + "@metamask/notification-services-controller": "^0.15.0", "@metamask/permission-controller": "^11.0.0", "@metamask/phishing-controller": "^12.0.3", "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.36.0", "@metamask/preferences-controller": "^15.0.1", - "@metamask/profile-sync-controller": "^2.0.0", + "@metamask/profile-sync-controller": "^3.0.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", "@metamask/react-native-payments": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index f8e9220e36c..e295783628c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4490,7 +4490,7 @@ resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-2.5.0.tgz#33921fa9c15eb1863f55dcd5f75467ae15614ebb" integrity sha512-+j7jEcp0P1OUMEpa/OIwfJs/ahBC/akwgWxaRTSWX2SWABvlUKBVRMtslfL94Qj2wN2xw8xjaUy5nSHqrznqDA== -"@metamask/controller-utils@^11.0.0", "@metamask/controller-utils@^11.3.0", "@metamask/controller-utils@^11.4.1", "@metamask/controller-utils@^11.4.3", "@metamask/controller-utils@^11.4.4": +"@metamask/controller-utils@^11.0.0", "@metamask/controller-utils@^11.3.0", "@metamask/controller-utils@^11.4.1", "@metamask/controller-utils@^11.4.4": version "11.4.4" resolved "https://registry.yarnpkg.com/@metamask/controller-utils/-/controller-utils-11.4.4.tgz#6e43e4cf53d34dad225bab8aaf4e7efcb1fe7623" integrity sha512-0/gKC6jxlj8KRzi0RjGDQnml6l4b46Da/AIqnGJMOC59zl4qD5UN1GM+mq7L5duw/m8sSHa7VbL1hL0l7Cw1pg== @@ -4883,7 +4883,7 @@ uuid "^9.0.1" webextension-polyfill "^0.12.0" -"@metamask/keyring-controller@^19.0.0", "@metamask/keyring-controller@^19.0.1": +"@metamask/keyring-controller@^19.0.1": version "19.0.1" resolved "https://registry.yarnpkg.com/@metamask/keyring-controller/-/keyring-controller-19.0.1.tgz#6fee40a46a780a720f4c864ea779673569be06a7" integrity sha512-6dNQBaJanAKEg7V0ksnWyqxHY1r3YCe910OF6DpnN97NZIPx3tba2zy32jbQWGPG6XaybfTG1xdUBtdYdeTpQA== @@ -4947,7 +4947,7 @@ resolved "https://registry.yarnpkg.com/@metamask/mobile-provider/-/mobile-provider-3.0.0.tgz#8a6a5a0874c8cbe4b468f63dfc57117d207f9595" integrity sha512-XwFJk0rd9lAZR5xS3VC7ypEhD7DvZR2gi2Ch6PHnODIqeS9Te3OdVKK5+jHI4his8v/zs6LWdFdlRtx5/jL96w== -"@metamask/network-controller@^22.0.2", "@metamask/network-controller@^22.1.0": +"@metamask/network-controller@^22.1.0": version "22.1.0" resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-22.1.0.tgz#b4c81a31fc52147d12131bfb16c77c1c1cfbe43c" integrity sha512-wvP2HUBQCWvlvBkuhqMNtd6+D/cJa343ABFLfCLNrQsk8QjdHPFw/QDIqC+QXTPxyrPWw9GL5akaLb//rmO1NA== @@ -4979,14 +4979,14 @@ "@ethersproject/providers" "^5.7.2" async-mutex "^0.3.1" -"@metamask/notification-services-controller@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-0.14.0.tgz#c2699db0c9e3329c2654a4a39d14176009963eaa" - integrity sha512-/OJW4j8PY66Gil+I/sJxstqXlR8ug+enOn1mCu0eqTDUwlrY+Qb3jOzww5mTWwN2q8D9IhkOHZm7HkM9dSWGSw== +"@metamask/notification-services-controller@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-0.15.0.tgz#d846fa49df62838a8ae48e80a8fee098730f06b0" + integrity sha512-RJtCI0GkVLStmhNoq9QNqSQNag6gD37iWU/qU19ds5PujSrtmfS5t2Sk6YRNV3SkRrfiIFrhGDToUDBDBu13OA== dependencies: "@contentful/rich-text-html-renderer" "^16.5.2" "@metamask/base-controller" "^7.0.2" - "@metamask/controller-utils" "^11.4.3" + "@metamask/controller-utils" "^11.4.4" "@metamask/utils" "^10.0.0" bignumber.js "^9.1.2" firebase "^10.11.0" @@ -5105,15 +5105,15 @@ "@metamask/base-controller" "^7.0.2" "@metamask/controller-utils" "^11.4.4" -"@metamask/profile-sync-controller@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@metamask/profile-sync-controller/-/profile-sync-controller-2.0.0.tgz#140297d4608373501b8dbe6fd86cbe3e63cdcc41" - integrity sha512-HdMlIz3Iun9wESUVcaH3y1pKNcnH+DJD0J0OKVaUxk4oKpS+u0QauEaMbvNps1ZxAY23x9gXTzeE3MMLbXYLgw== +"@metamask/profile-sync-controller@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@metamask/profile-sync-controller/-/profile-sync-controller-3.0.0.tgz#52cdb1f370ba6c8ce580fb9f9d1e9a5b1f8b9014" + integrity sha512-OlNOlWcHLlX4QNKR/J0O4IwgtO46EYA3OPzntmDk7zlFtd3I61qooE3EnRWH6aYPQsf7GcsM2aP44cKVJyV4mg== dependencies: "@metamask/base-controller" "^7.0.2" "@metamask/keyring-api" "^10.1.0" - "@metamask/keyring-controller" "^19.0.0" - "@metamask/network-controller" "^22.0.2" + "@metamask/keyring-controller" "^19.0.1" + "@metamask/network-controller" "^22.1.0" "@metamask/snaps-sdk" "^6.7.0" "@metamask/snaps-utils" "^8.3.0" "@noble/ciphers" "^0.5.2" From 0f4638315513d894a0b1c29f3ed459f3cd01a5ba Mon Sep 17 00:00:00 2001 From: jvbriones <1674192+jvbriones@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:50:50 +0100 Subject: [PATCH 02/15] chore: update bug template to include feature branches (#12623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Github bug template updated to include feature branches as a development stage ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .github/ISSUE_TEMPLATE/bug-report.yml | 3 ++- .github/guidelines/LABELING_GUIDELINES.md | 3 ++- .../scripts/check-template-and-add-labels.ts | 20 ++++++++++++++----- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 31ee91b2191..4214943e066 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -57,7 +57,8 @@ body: - In production (default) - In beta - During release testing - - On the development branch + - On main branch + - On a feature branch validations: required: true - type: input diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index ee49fad0639..4b36fc4275e 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -25,7 +25,8 @@ To merge your PR one of the following QA labels are required: - **Run E2E Smoke**: This label will kick-off E2E testing and trigger a check to make sure the E2E tests pass. ### Optional labels: -- **regression-develop**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on the development branch, i.e., `main`, but is not yet released in production. +- **regression-main**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on the development branch, i.e., `main`, but is not yet released in production. +- **feature-branch-bug**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on a feature branch, i.e., before merging to `main`. ### Labels prohibited when PR needs to be merged: Any PR that includes one of the following labels can not be merged: diff --git a/.github/scripts/check-template-and-add-labels.ts b/.github/scripts/check-template-and-add-labels.ts index e0a59e21d8e..fef8a5585d1 100644 --- a/.github/scripts/check-template-and-add-labels.ts +++ b/.github/scripts/check-template-and-add-labels.ts @@ -20,7 +20,8 @@ import { TemplateType, templates } from './shared/template'; import { retrievePullRequest } from './shared/pull-request'; enum RegressionStage { - Development, + DevelopmentFeature, + DevelopmentMain, Testing, Beta, Production @@ -202,8 +203,10 @@ function extractRegressionStageFromBugReportIssueBody( const extractedAnswer = match ? match[1].trim() : undefined; switch (extractedAnswer) { - case 'On the development branch': - return RegressionStage.Development; + case 'On a feature branch': + return RegressionStage.DevelopmentFeature; + case 'On main branch': + return RegressionStage.DevelopmentMain; case 'During release testing': return RegressionStage.Testing; case 'In beta': @@ -317,11 +320,18 @@ async function userBelongsToMetaMaskOrg( // This function crafts appropriate label, corresponding to regression stage and release version. function craftRegressionLabel(regressionStage: RegressionStage | undefined, releaseVersion: string | undefined): Label { switch (regressionStage) { - case RegressionStage.Development: + case RegressionStage.DevelopmentFeature: + return { + name: `feature-branch-bug`, + color: '5319E7', // violet + description: `bug that was found on a feature branch, but not yet merged in main branch`, + }; + + case RegressionStage.DevelopmentMain: return { name: `regression-develop`, color: '5319E7', // violet - description: `Regression bug that was found on development branch, but not yet present in production`, + description: `Regression bug that was found on main branch, but not yet present in production`, }; case RegressionStage.Testing: From 800d116b17c3cc8ee0a9f987fbcd780a787dd5d6 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Tue, 10 Dec 2024 10:18:29 -0700 Subject: [PATCH 03/15] feat: multi chain asset list (#12431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR introduces the Unified Asset List feature to MetaMask Mobile, providing users with a consolidated view of their assets across all supported blockchain networks. This enhancement improves the user experience by eliminating the need to switch between networks to view or manage assets, making asset management more intuitive and efficient. We will followup with a PR to fix TS feedback we had! (Not only TS issues but also [this](https://github.com/MetaMask/metamask-mobile/pull/12431#discussion_r1878052067 and [this](https://github.com/MetaMask/metamask-mobile/pull/12431#discussion_r1878048047)) ## **Related issues** Fixes: #12462 ## **Manual testing steps** Build using `PORTFOLIO_VIEW` flag ``` PORTFOLIO_VIEW=true yarn watch yarn start:ios yarn start:android ``` 1. Go to the wallet page 2. Select all network on the network filter 3. Check the list of assets 4. Click on each asset with the network filter on "All Networks" and "Current Network" 5. Test send/swap flows with testnet networks to confirm everything still works 6. Importing all tokens should work when "All Networks" filter is on 7. Importing networks for a specific network should work when the "Current Network" filter is on 8. Aggregated balance should chance according to the network filter ## **Screenshots/Recordings** | Before | After | |:---:|:---:| |![before](https://github.com/user-attachments/assets/449bd3ef-1f69-4cf9-bb93-f5a28838e11b)|![after](https://github.com/user-attachments/assets/26209026-6863-4085-85ed-d541ca4fa720)| ### **Before** before_screenshot ### **After** after_screenshot ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: salimtb Co-authored-by: sahar-fehri --- .../UI/AccountApproval/index.test.tsx | 7 + .../AccountFromToInfoCard.test.tsx | 8 +- .../UI/AssetOverview/AssetOverview.test.tsx | 86 +- .../UI/AssetOverview/AssetOverview.tsx | 237 ++- .../UI/AssetOverview/Balance/Balance.tsx | 15 +- .../UI/AssetOverview/Balance/index.test.tsx | 82 +- .../UI/AssetOverview/Price/Price.tsx | 5 +- .../TokenDetails/TokenDetails.test.tsx | 107 +- .../TokenDetails/TokenDetails.tsx | 40 +- .../TokenDetailsList.test.tsx | 1 + .../__snapshots__/AssetOverview.test.tsx.snap | 1136 +++++++++++ app/components/UI/Navbar/index.js | 2 + app/components/UI/NavbarTitle/index.js | 1 - .../__snapshots__/index.test.tsx.snap | 30 +- app/components/UI/NetworkModal/index.test.tsx | 74 +- app/components/UI/NetworkModal/index.tsx | 50 +- .../UI/PaymentRequest/index.test.tsx | 7 +- .../StakeInputView/StakeInputView.test.tsx | 17 + .../UnstakeInputView.test.tsx | 16 + .../StakingBalance/StakingBalance.test.tsx | 11 + .../StakingBalance.test.tsx.snap | 468 +++++ .../UI/Stake/hooks/useStakingChain.test.tsx | 41 +- .../UI/Stake/hooks/useStakingChain.ts | 9 + .../TokenList/PortfolioBalance/index.tsx | 6 +- .../TokenList/TokenListFooter/index.tsx | 10 +- .../Tokens/TokenList/TokenListItem/index.tsx | 231 ++- .../TokenFilterBottomSheet.tsx | 4 +- .../Tokens/__snapshots__/index.test.tsx.snap | 1767 +++++++++++++++++ app/components/UI/Tokens/index.test.tsx | 289 ++- app/components/UI/Tokens/index.tsx | 184 +- app/components/UI/Tokens/styles.ts | 10 + app/components/UI/Tokens/types.ts | 1 + .../deriveBalanceFromAssetMarketDetails.ts | 12 +- .../util/enableAllNetworksFilter.test.ts | 164 ++ .../UI/Tokens/util/filterAssets.test.ts | 183 ++ app/components/UI/Tokens/util/filterAssets.ts | 91 + app/components/UI/Transactions/index.js | 12 +- app/components/Views/Asset/index.js | 23 +- app/components/Views/Asset/index.test.js | 7 + .../Views/AssetDetails/AssetsDetails.test.tsx | 30 +- app/components/Views/AssetDetails/index.tsx | 110 +- .../Views/AssetOptions/AssetOptions.test.tsx | 191 +- .../Views/AssetOptions/AssetOptions.tsx | 58 +- .../__snapshots__/AssetOptions.test.tsx.snap | 136 ++ .../Views/DetectedTokens/components/Token.tsx | 4 +- app/components/Views/DetectedTokens/index.tsx | 13 +- .../NetworkSelector/NetworkSelector.test.tsx | 26 + .../Views/NetworkSelector/NetworkSelector.tsx | 31 +- .../QRTabSwitcher/QRTabSwitcher.test.tsx | 11 +- app/components/Views/Wallet/index.tsx | 2 +- .../ApproveView/Approve/index.test.tsx | 51 + .../SendFlow/Amount/index.test.tsx | 234 ++- .../VerifyContractDetails.test.tsx | 23 +- .../ApproveTransactionReview/index.test.tsx | 22 +- .../AssetPollingProvider.test.tsx | 5 +- .../AssetPolling/AssetPollingProvider.tsx | 2 + .../AssetPolling/useAccountTrackerPolling.ts | 54 + .../useTokenBalancesPolling.test.ts | 86 +- .../AssetPolling/useTokenBalancesPolling.ts | 23 +- .../useTokenDetectionPolling.test.ts | 232 ++- .../AssetPolling/useTokenDetectionPolling.ts | 33 +- .../AssetPolling/useTokenListPolling.test.ts | 61 +- .../hooks/AssetPolling/useTokenListPolling.ts | 20 +- .../AssetPolling/useTokenRatesPolling.ts | 2 +- .../hooks/useAccounts/useAccounts.test.ts | 3 + app/components/hooks/useAccounts/utils.ts | 14 +- .../useGetFormattedTokensPerChain.test.ts | 3 - app/reducers/swaps/index.js | 54 +- app/selectors/accountTrackerController.ts | 1 + app/selectors/accountsController.ts | 1 + app/selectors/currencyRateController.test.ts | 21 +- app/selectors/currencyRateController.ts | 16 +- app/selectors/multichain.test.ts | 188 ++ app/selectors/multichain.ts | 213 ++ app/selectors/networkController.test.ts | 155 ++ app/selectors/networkController.ts | 18 +- app/selectors/tokenBalancesController.test.ts | 35 +- app/selectors/tokenBalancesController.ts | 14 +- app/selectors/tokenRatesController.ts | 5 + app/selectors/tokensController.test.ts | 72 +- app/selectors/tokensController.ts | 41 +- app/util/networks/index.js | 4 +- e2e/specs/settings/fiat-on-testnets.spec.js | 1 + .../@metamask+assets-controllers+45.1.1.patch | 110 +- 84 files changed, 7385 insertions(+), 488 deletions(-) create mode 100644 app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts create mode 100644 app/components/UI/Tokens/util/filterAssets.test.ts create mode 100644 app/components/UI/Tokens/util/filterAssets.ts create mode 100644 app/components/hooks/AssetPolling/useAccountTrackerPolling.ts create mode 100644 app/selectors/multichain.test.ts create mode 100644 app/selectors/networkController.test.ts diff --git a/app/components/UI/AccountApproval/index.test.tsx b/app/components/UI/AccountApproval/index.test.tsx index 0afe79dd39e..29e438ead16 100644 --- a/app/components/UI/AccountApproval/index.test.tsx +++ b/app/components/UI/AccountApproval/index.test.tsx @@ -48,6 +48,13 @@ const mockInitialState = { }, }, }, + TokensController: { + allTokens: { + '0x1': { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': [], + }, + }, + }, }, }, }; diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx index 68db9cb046b..c650e8640f5 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx @@ -38,7 +38,13 @@ const mockInitialState: DeepPartial = { }, }, TokenBalancesController: { - tokenBalances: { }, + tokenBalances: { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': { + '0x5': { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': '0x2b46', + }, + }, + }, }, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, }, diff --git a/app/components/UI/AssetOverview/AssetOverview.test.tsx b/app/components/UI/AssetOverview/AssetOverview.test.tsx index e84206b8c13..8be0735dace 100644 --- a/app/components/UI/AssetOverview/AssetOverview.test.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.test.tsx @@ -10,8 +10,13 @@ import { MOCK_ADDRESS_2, } from '../../../util/test/accountsControllerTestUtils'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; const MOCK_CHAIN_ID = '0x1'; @@ -43,6 +48,15 @@ const mockInitialState = { }, } as const, }, + CurrencyRateController: { + conversionRate: { + ETH: { + conversionDate: 1732572535.47, + conversionRate: 3432.53, + usdConversionRate: 3432.53, + }, + }, + }, }, settings: { primaryCurrency: 'ETH', @@ -51,6 +65,15 @@ const mockInitialState = { const mockNavigate = jest.fn(); const navigate = mockNavigate; +const mockNetworkConfiguration = { + rpcEndpoints: [ + { + networkClientId: 'mockNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, +}; + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -72,9 +95,21 @@ jest.mock('../../hooks/useStyles', () => ({ }), })); +jest.mock('../../../core/Engine', () => ({ + context: { + NetworkController: { + getNetworkConfigurationByChainId: jest + .fn() + .mockReturnValue(mockNetworkConfiguration), + setActiveNetwork: jest.fn().mockResolvedValue(undefined), + }, + }, +})); + const asset = { balance: '400', balanceFiat: '1500', + chainId: MOCK_CHAIN_ID, logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg', symbol: 'ETH', name: 'Ethereum', @@ -87,6 +122,10 @@ const asset = { }; describe('AssetOverview', () => { + beforeEach(() => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); + }); + it('should render correctly', async () => { const container = renderWithProvider( , @@ -95,6 +134,16 @@ describe('AssetOverview', () => { expect(container).toMatchSnapshot(); }); + it('should render correctly when portfolio view is enabled', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const container = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(container).toMatchSnapshot(); + }); + it('should handle buy button press', async () => { const { getByTestId } = renderWithProvider( , @@ -133,13 +182,34 @@ describe('AssetOverview', () => { const swapButton = getByTestId('token-swap-button'); fireEvent.press(swapButton); - expect(navigate).toHaveBeenCalledWith('Swaps', { - params: { - sourcePage: 'MainView', - sourceToken: asset.address, - }, - screen: 'SwapsAmountView', - }); + if (isPortfolioViewEnabled()) { + expect(navigate).toHaveBeenCalledTimes(3); + expect(navigate).toHaveBeenNthCalledWith(1, 'RampBuy', { + screen: 'GetStarted', + params: { + address: asset.address, + chainId: getDecimalChainId(MOCK_CHAIN_ID), + }, + }); + expect(navigate).toHaveBeenNthCalledWith(2, 'SendFlowView', {}); + expect(navigate).toHaveBeenNthCalledWith(3, 'Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + address: asset.address, + chainId: MOCK_CHAIN_ID, + }, + }); + } else { + expect(navigate).toHaveBeenCalledWith('Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + sourceToken: asset.address, + chainId: '0x1', + }, + }); + } }); it('should not render swap button if displaySwapsButton is false', async () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 55ce2b8f222..dcb345b1904 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -1,8 +1,9 @@ -import { zeroAddress } from 'ethereumjs-util'; import React, { useCallback, useEffect } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { strings } from '../../../../locales/i18n'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; import { newAssetTransaction } from '../../../actions/transaction'; @@ -11,15 +12,26 @@ import Engine from '../../../core/Engine'; import { selectChainId, selectTicker, + selectNativeCurrencyByChainId, } from '../../../selectors/networkController'; import { selectConversionRate, selectCurrentCurrency, + selectCurrencyRates, } from '../../../selectors/currencyRateController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; +import { + selectContractExchangeRates, + selectTokenMarketData, +} from '../../../selectors/tokenRatesController'; import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; -import { selectContractBalances } from '../../../selectors/tokenBalancesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { + selectContractBalances, + selectTokensBalances, +} from '../../../selectors/tokenBalancesController'; +import { + selectSelectedInternalAccountAddress, + selectSelectedInternalAccountFormattedAddress, +} from '../../../selectors/accountsController'; import Logger from '../../../util/Logger'; import { safeToChecksumAddress } from '../../../util/address'; import { @@ -46,9 +58,12 @@ import Routes from '../../../constants/navigation/Routes'; import TokenDetails from './TokenDetails'; import { RootState } from '../../../reducers'; import useGoToBridge from '../Bridge/utils/useGoToBridge'; -import SwapsController from '@metamask/swaps-controller'; +import SwapsController, { swapsUtils } from '@metamask/swaps-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; import { TokenI } from '../Tokens/types'; @@ -67,8 +82,12 @@ const AssetOverview: React.FC = ({ }: AssetOverviewProps) => { const navigation = useNavigation(); const [timePeriod, setTimePeriod] = React.useState('1d'); - const currentCurrency = useSelector(selectCurrentCurrency); + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); const conversionRate = useSelector(selectConversionRate); + const conversionRateByTicker = useSelector(selectCurrencyRates); + const currentCurrency = useSelector(selectCurrentCurrency); const accountsByChainId = useSelector(selectAccountsByChainId); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, @@ -79,12 +98,35 @@ const AssetOverview: React.FC = ({ ); const { trackEvent, createEventBuilder } = useMetrics(); const tokenExchangeRates = useSelector(selectContractExchangeRates); + const allTokenMarketData = useSelector(selectTokenMarketData); const tokenBalances = useSelector(selectContractBalances); - const chainId = useSelector((state: RootState) => selectChainId(state)); - const ticker = useSelector((state: RootState) => selectTicker(state)); + const selectedChainId = useSelector((state: RootState) => + selectChainId(state), + ); + const selectedTicker = useSelector((state: RootState) => selectTicker(state)); + + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + + const multiChainTokenBalance = useSelector(selectTokensBalances); + const chainId = isPortfolioViewEnabled() + ? (asset.chainId as Hex) + : selectedChainId; + const ticker = isPortfolioViewEnabled() ? nativeCurrency : selectedTicker; + + let currentAddress: Hex; + + if (isPortfolioViewEnabled()) { + currentAddress = asset.address as Hex; + } else { + currentAddress = asset.isETH + ? getNativeTokenAddress(chainId as Hex) + : (asset.address as Hex); + } const { data: prices = [], isLoading } = useTokenHistoricalPrices({ - address: asset.isETH ? zeroAddress() : asset.address, + address: currentAddress, chainId, timePeriod, vsCurrency: currentCurrency, @@ -119,7 +161,41 @@ const AssetOverview: React.FC = ({ }); }; + const handleSwapNavigation = useCallback(() => { + navigation.navigate('Swaps', { + screen: 'SwapsAmountView', + params: { + sourceToken: asset.address ?? swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS, + sourcePage: 'MainView', + chainId: asset.chainId, + }, + }); + }, [navigation, asset.address, asset.chainId]); + const onSend = async () => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + await NetworkController.setActiveNetwork(networkClientId as string); + } + } if (asset.isETH && ticker) { dispatch(newAssetTransaction(getEther(ticker))); } else { @@ -128,25 +204,58 @@ const AssetOverview: React.FC = ({ navigation.navigate('SendFlowView', {}); }; - const goToSwaps = () => { - navigation.navigate('Swaps', { - screen: 'SwapsAmountView', - params: { - sourceToken: asset.address, - sourcePage: 'MainView', - }, - }); - trackEvent( - createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) - .addProperties({ - text: 'Swap', - tokenSymbol: '', - location: 'TokenDetails', - chain_id: getDecimalChainId(chainId), - }) - .build(), - ); - }; + const goToSwaps = useCallback(() => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + NetworkController.setActiveNetwork(networkClientId as string).then( + () => { + setTimeout(() => { + handleSwapNavigation(); + }, 500); + }, + ); + } else { + handleSwapNavigation(); + } + } else { + handleSwapNavigation(); + trackEvent( + createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) + .addProperties({ + text: 'Swap', + tokenSymbol: '', + location: 'TokenDetails', + chain_id: getDecimalChainId(asset.chainId), + }) + .build(), + ); + } + }, [ + navigation, + asset.chainId, + selectedChainId, + trackEvent, + createEventBuilder, + handleSwapNavigation, + ]); + const onBuy = () => { navigation.navigate( ...createBuyNavigationDetails({ @@ -209,14 +318,21 @@ const AssetOverview: React.FC = ({ )), [handleSelectTimePeriod, timePeriod], ); - const itemAddress = safeToChecksumAddress(asset.address); - const exchangeRate = itemAddress - ? tokenExchangeRates?.[itemAddress]?.price - : undefined; + + let exchangeRate: number | undefined; + if (!isPortfolioViewEnabled()) { + exchangeRate = itemAddress + ? tokenExchangeRates?.[itemAddress as Hex]?.price + : undefined; + } else { + const currentChainId = chainId as Hex; + exchangeRate = + allTokenMarketData?.[currentChainId]?.[itemAddress as Hex]?.price; + } let balance, balanceFiat; - if (asset.isETH) { + if (asset.isETH || asset.isNative) { balance = renderFromWei( //@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance, @@ -230,9 +346,22 @@ const AssetOverview: React.FC = ({ currentCurrency, ); } else { + const multiChainTokenBalanceHex = + itemAddress && + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId as Hex + ]?.[itemAddress as Hex]; + + const selectedTokenBalanceHex = + itemAddress && tokenBalances?.[itemAddress as Hex]; + + const tokenBalanceHex = isPortfolioViewEnabled() + ? multiChainTokenBalanceHex + : selectedTokenBalanceHex; + balance = - itemAddress && tokenBalances?.[itemAddress] - ? renderFromTokenMinimalUnit(tokenBalances[itemAddress], asset.decimals) + itemAddress && tokenBalanceHex + ? renderFromTokenMinimalUnit(tokenBalanceHex, asset.decimals) : 0; balanceFiat = balanceToFiat( balance, @@ -243,23 +372,37 @@ const AssetOverview: React.FC = ({ } let mainBalance, secondaryBalance; - if (primaryCurrency === 'ETH') { - mainBalance = `${balance} ${asset.symbol}`; - secondaryBalance = balanceFiat; + if (!isPortfolioViewEnabled()) { + if (primaryCurrency === 'ETH') { + mainBalance = `${balance} ${asset.symbol}`; + secondaryBalance = balanceFiat; + } else { + mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; + secondaryBalance = !balanceFiat + ? balanceFiat + : `${balance} ${asset.symbol}`; + } } else { - mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; - secondaryBalance = !balanceFiat - ? balanceFiat - : `${balance} ${asset.symbol}`; + mainBalance = `${balance} ${asset.ticker}`; + secondaryBalance = exchangeRate ? asset.balanceFiat : ''; } let currentPrice = 0; let priceDiff = 0; - if (asset.isETH) { - currentPrice = conversionRate || 0; - } else if (exchangeRate && conversionRate) { - currentPrice = exchangeRate * conversionRate; + if (!isPortfolioViewEnabled()) { + if (asset.isETH) { + currentPrice = conversionRate || 0; + } else if (exchangeRate && conversionRate) { + currentPrice = exchangeRate * conversionRate; + } + } else { + const tickerConversionRate = + conversionRateByTicker?.[nativeCurrency]?.conversionRate ?? 0; + currentPrice = + exchangeRate && tickerConversionRate + ? exchangeRate * tickerConversionRate + : 0; } const comparePrice = prices[0]?.[1] || 0; diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx index afc9b9379af..82662417e44 100644 --- a/app/components/UI/AssetOverview/Balance/Balance.tsx +++ b/app/components/UI/AssetOverview/Balance/Balance.tsx @@ -14,7 +14,7 @@ import { isLineaMainnetByChainId, isMainnetByChainId, isTestNet, - isPortfolioViewEnabledFunction, + isPortfolioViewEnabled, } from '../../../../util/networks'; import images from '../../../../images/image-icons'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; @@ -46,7 +46,7 @@ interface BalanceProps { export const NetworkBadgeSource = (chainId: Hex, ticker: string) => { const isMainnet = isMainnetByChainId(chainId); const isLineaMainnet = isLineaMainnetByChainId(chainId); - if (!isPortfolioViewEnabledFunction()) { + if (!isPortfolioViewEnabled()) { if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); if (isMainnet) return images.ETHEREUM; @@ -95,14 +95,16 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { const networkName = useSelector(selectNetworkName); const chainId = useSelector(selectChainId); + const tokenChainId = isPortfolioViewEnabled() ? asset.chainId : chainId; + const ticker = asset.symbol; const renderNetworkAvatar = useCallback(() => { - if (!isPortfolioViewEnabledFunction() && asset.isETH) { + if (!isPortfolioViewEnabled() && asset.isETH) { return ; } - if (isPortfolioViewEnabledFunction() && asset.isNative) { + if (isPortfolioViewEnabled() && asset.isNative) { return ( { balance={secondaryBalance} onPress={() => !asset.isETH && + !asset.isNative && navigation.navigate('AssetDetails', { chainId: asset.chainId, address: asset.address, @@ -153,8 +156,8 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { badgeElement={ } > diff --git a/app/components/UI/AssetOverview/Balance/index.test.tsx b/app/components/UI/AssetOverview/Balance/index.test.tsx index f3070b52a3f..ba5dd5f2fc3 100644 --- a/app/components/UI/AssetOverview/Balance/index.test.tsx +++ b/app/components/UI/AssetOverview/Balance/index.test.tsx @@ -8,7 +8,7 @@ import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { NetworkBadgeSource } from './Balance'; -import { isPortfolioViewEnabledFunction } from '../../../../util/networks'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -37,6 +37,8 @@ const mockDAI = { symbol: 'DAI', isETH: false, logo: 'image-path', + chainId: '0x1', + isNative: false, }; const mockETH = { @@ -52,6 +54,8 @@ const mockETH = { symbol: 'ETH', isETH: true, logo: 'image-path', + chainId: '0x1', + isNative: true, }; const mockInitialState = { @@ -67,7 +71,7 @@ jest.mock('../../../../util/networks', () => ({ jest.mock('../../../../util/networks', () => ({ ...jest.requireActual('../../../../util/networks'), - isPortfolioViewEnabledFunction: jest.fn(), + isPortfolioViewEnabled: jest.fn(), })); describe('Balance', () => { @@ -95,23 +99,27 @@ describe('Balance', () => { jest.clearAllMocks(); }); - it('should render correctly with a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should render correctly without a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); + if (!isPortfolioViewEnabled()) { + it('should render correctly with a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } + + if (!isPortfolioViewEnabled()) { + it('should render correctly without a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } it('should fire navigation event for non native tokens', () => { const { queryByTestId } = render( @@ -155,11 +163,39 @@ describe('Balance', () => { }); it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { - (isPortfolioViewEnabledFunction as jest.Mock).mockImplementation( - () => true, - ); + if (isPortfolioViewEnabled()) { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + } + }); + }); +}); + +describe('NetworkBadgeSource', () => { + it('returns testnet image for a testnet chainId', () => { + const result = NetworkBadgeSource('0xaa36a7', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns mainnet Ethereum image for mainnet chainId', () => { + const result = NetworkBadgeSource('0x1', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId', () => { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + }); + + it('returns undefined if no image is found', () => { + const result = NetworkBadgeSource('0x999', 'UNKNOWN'); + expect(result).toBeUndefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { + if (isPortfolioViewEnabled()) { const result = NetworkBadgeSource('0xe708', 'LINEA'); expect(result).toBeDefined(); - }); + } }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 867441a9a87..9e65e259d4c 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -90,7 +90,10 @@ const Price = ({ {asset.symbol} )} {!isNaN(price) && ( - + {isLoading ? ( diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index 7e8d341492d..881977207bb 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Hex } from '@metamask/utils'; +import { MarketDataDetails } from '@metamask/assets-controllers'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import TokenDetails from './'; @@ -8,9 +10,12 @@ import { selectConversionRate, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; +import { + selectProviderConfig, + selectTicker, +} from '../../../../selectors/networkController'; // eslint-disable-next-line import/no-namespace import * as reactRedux from 'react-redux'; - jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: jest.fn(), context: { @@ -80,14 +85,60 @@ const mockContractExchangeRates = { }, }; +const mockTokenMarketDataByChainId: Record< + Hex, + Record +> = { + '0x1': { + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + allTimeHigh: 0.00045049491236145674, + allTimeLow: 0.00032567089582484455, + circulatingSupply: 5210102796.32321, + currency: 'ETH', + dilutedMarketCap: 1923097.9291743594, + high1d: 0.0003703658992610993, + low1d: 0.00036798603064620616, + marketCap: 1923097.9291743594, + marketCapPercentChange1d: -0.03026, + price: 0.00036902069191213795, + priceChange1d: 0.00134711, + pricePercentChange14d: -0.01961306580879152, + pricePercentChange1d: 0.13497913251736524, + pricePercentChange1h: -0.15571963819527113, + pricePercentChange1y: -0.01608509228365429, + pricePercentChange200d: -0.0287692372426721, + pricePercentChange30d: -0.08401729203937018, + pricePercentChange7d: 0.019578202262256407, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + totalVolume: 54440.464606773865, + }, + }, +}; + describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); it('should render correctly', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -133,10 +184,26 @@ describe('TokenDetails', () => { expect(toJSON()).toMatchSnapshot(); }); - it('should render TokenDetils without MarketDetails when marketData is null', () => { + it('should render Token Details without Market Details when marketData is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: {}, + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + useSelectorSpy.mockImplementation((selectorOrCallback) => { + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -145,6 +212,10 @@ describe('TokenDetails', () => { return mockExchangeRate; case selectCurrentCurrency: return mockCurrentCurrency; + case selectProviderConfig: + return { ticker: 'ETH' }; + case selectTicker: + return 'ETH'; default: return undefined; } @@ -162,8 +233,24 @@ describe('TokenDetails', () => { it('should render MarketDetails without TokenDetails when tokenList is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return {}; case selectContractExchangeRates: @@ -179,9 +266,7 @@ describe('TokenDetails', () => { const { getByText, queryByText } = renderWithProvider( , - { - state: initialState, - }, + { state: initialState }, ); expect(queryByText('Token details')).toBeNull(); expect(getByText('Market details')).toBeDefined(); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 368e2352d23..54df1781873 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -1,4 +1,6 @@ import { zeroAddress } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; +import { RootState } from '../../../../reducers'; import React from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; @@ -7,11 +9,16 @@ import { useStyles } from '../../../../component-library/hooks'; import styleSheet from './TokenDetails.styles'; import { safeToChecksumAddress } from '../../../../util/address'; import { selectTokenList } from '../../../../selectors/tokenListController'; -import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController'; import { - selectConversionRate, + selectTokenMarketDataByChainId, + selectContractExchangeRates, +} from '../../../../selectors/tokenRatesController'; +import { + selectConversionRateBySymbol, selectCurrentCurrency, + selectConversionRate, } from '../../../../selectors/currencyRateController'; +import { selectNativeCurrencyByChainId } from '../../../../selectors/networkController'; import { convertDecimalToPercentage, localizeLargeNumber, @@ -23,6 +30,7 @@ import MarketDetailsList from './MarketDetailsList'; import { TokenI } from '../../Tokens/types'; import { isPooledStakingFeatureEnabled } from '../../Stake/constants'; import StakingEarnings from '../../Stake/components/StakingEarnings'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; export interface TokenDetails { contractAddress: string | null; @@ -46,20 +54,36 @@ interface TokenDetailsProps { const TokenDetails: React.FC = ({ asset }) => { const { styles } = useStyles(styleSheet, {}); - const tokenList = useSelector(selectTokenList); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const conversionRate = useSelector(selectConversionRate); + const tokenExchangeRatesByChainId = useSelector((state: RootState) => + selectTokenMarketDataByChainId(state, asset.chainId as Hex), + ); + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + const tokenExchangeRatesLegacy = useSelector(selectContractExchangeRates); + const conversionRateLegacy = useSelector(selectConversionRate); + const conversionRateBySymbol = useSelector((state: RootState) => + selectConversionRateBySymbol(state, nativeCurrency), + ); const currentCurrency = useSelector(selectCurrentCurrency); const tokenContractAddress = safeToChecksumAddress(asset.address); + const tokenList = useSelector(selectTokenList); + + const conversionRate = isPortfolioViewEnabled() + ? conversionRateBySymbol + : conversionRateLegacy; + const tokenExchangeRates = isPortfolioViewEnabled() + ? tokenExchangeRatesByChainId + : tokenExchangeRatesLegacy; let tokenMetadata; let marketData; if (asset.isETH) { - marketData = tokenExchangeRates?.[zeroAddress() as `0x${string}`]; - } else if (!asset.isETH && tokenContractAddress) { + marketData = tokenExchangeRates?.[zeroAddress() as Hex]; + } else if (tokenContractAddress) { tokenMetadata = tokenList?.[tokenContractAddress.toLowerCase()]; - marketData = tokenExchangeRates?.[tokenContractAddress]; + marketData = tokenExchangeRates?.[tokenContractAddress as Hex]; } else { Logger.log('cannot find contract address'); return null; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx index 1b0c37923d0..64f1e8da9d0 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx @@ -19,6 +19,7 @@ describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); + it('should render correctly', () => { const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); useDispatchSpy.mockImplementation(() => jest.fn()); diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index cc3cbd21a25..4ec605677fb 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -1133,3 +1133,1139 @@ exports[`AssetOverview should render correctly 1`] = ` `; + +exports[`AssetOverview should render correctly when portfolio view is enabled 1`] = ` + + + + + Ethereum + ( + ETH + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1D + + + + + 1W + + + + + 1M + + + + + 3M + + + + + 1Y + + + + + 3Y + + + + + + + + + + + + + + + + Buy + + + + + + + + + + + + + + Swap + + + + + + + + + + + + + + Bridge + + + + + + + + + + + + + + Send + + + + + + + + + + + + + + Receive + + + + + + Your balance + + + + + + + + + + + + + + + + + + Ethereum + + + + 0 undefined + + + + + + + +`; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0ce39e4eb0a..b7763524b3e 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1361,6 +1361,7 @@ export function getNetworkNavbarOptions( onRightPress = undefined, disableNetwork = false, contentOffset = 0, + networkName = '', ) { const innerStyles = StyleSheet.create({ headerStyle: { @@ -1385,6 +1386,7 @@ export function getNetworkNavbarOptions( disableNetwork={disableNetwork} title={title} translate={translate} + networkName={networkName} /> ), headerLeft: () => ( diff --git a/app/components/UI/NavbarTitle/index.js b/app/components/UI/NavbarTitle/index.js index 7cc2846d099..d1ce5408209 100644 --- a/app/components/UI/NavbarTitle/index.js +++ b/app/components/UI/NavbarTitle/index.js @@ -6,7 +6,6 @@ import { TouchableOpacity, View, StyleSheet } from 'react-native'; import { fontStyles, colors as importedColors } from '../../../styles/common'; import Networks, { getDecimalChainId } from '../../../util/networks'; import { strings } from '../../../../locales/i18n'; -import Device from '../../../util/device'; import { ThemeContext, mockTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../core/Analytics'; diff --git a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap index fa6b7d2bf5b..fb2c30e33a1 100644 --- a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap @@ -237,9 +237,8 @@ exports[`NetworkDetails renders correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#f2f4f6", + "backgroundColor": "#ffffff", "borderRadius": 8, - "borderWidth": 1, "height": 16, "justifyContent": "center", "overflow": "hidden", @@ -248,21 +247,24 @@ exports[`NetworkDetails renders correctly 1`] = ` } testID="network-avatar-picker" > - - T - + testID="network-avatar-image" + /> ({ + context: { + PreferencesController: { + setTokenNetworkFilter: jest.fn(), + }, + NetworkController: { + updateNetwork: jest.fn(), + addNetwork: jest.fn(), + setActiveNetwork: jest.fn(), + }, + }, +})); + interface NetworkProps { isVisible: boolean; onClose: () => void; @@ -18,27 +36,46 @@ interface NetworkProps { showPopularNetworkModal: boolean; } +const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), + useDispatch: () => mockDispatch, useSelector: jest.fn(), })); + describe('NetworkDetails', () => { const props: NetworkProps = { isVisible: true, - onClose: () => ({}), + onClose: jest.fn(), networkConfiguration: { - chainId: '1', + chainId: '0x1', nickname: 'Test Network', - ticker: 'Test', + ticker: 'TEST', rpcUrl: 'https://localhost:8545', formattedRpcUrl: 'https://localhost:8545', rpcPrefs: { blockExplorerUrl: 'https://test.com', imageUrl: 'image' }, }, - navigation: 'navigation', + navigation: { navigate: jest.fn(), goBack: jest.fn() }, shouldNetworkSwitchPopToWallet: true, showPopularNetworkModal: true, }; + + beforeEach(() => { + jest.clearAllMocks(); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectNetworkName) return 'Ethereum Main Network'; + if (selector === selectUseSafeChainsListValidation) return true; + return {}; + }); + }); + + const renderWithTheme = (component: React.ReactNode) => + render( + + {component} + , + ); + it('renders correctly', () => { (useSelector as jest.MockedFn).mockImplementation( (selector) => { @@ -46,8 +83,31 @@ describe('NetworkDetails', () => { if (selector === selectUseSafeChainsListValidation) return true; }, ); - const { toJSON } = render(); + const { toJSON } = renderWithTheme(); expect(toJSON()).toMatchSnapshot(); }); + + it('should call setTokenNetworkFilter when switching networks', async () => { + const { getByTestId } = renderWithTheme(); + + const approveButton = getByTestId( + NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON, + ); + fireEvent.press(approveButton); + + const switchButton = getByTestId( + NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON, + ); + await act(async () => { + fireEvent.press(switchButton); + }); + + expect( + Engine.context.PreferencesController.setTokenNetworkFilter, + ).toHaveBeenCalledWith({ + [props.networkConfiguration.chainId]: true, + }); + expect(mockDispatch).toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 53c37e95c45..30d7d4e1efd 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -22,7 +22,10 @@ import { import { useTheme } from '../../../util/theme'; import { networkSwitched } from '../../../actions/onboardNetwork'; import { NetworkApprovalBottomSheetSelectorsIDs } from '../../../../e2e/selectors/Network/NetworkApprovalBottomSheet.selectors'; -import { selectUseSafeChainsListValidation } from '../../../selectors/preferencesController'; +import { + selectTokenNetworkFilter, + selectUseSafeChainsListValidation, +} from '../../../selectors/preferencesController'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -34,7 +37,10 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { toHex } from '@metamask/controller-utils'; import { rpcIdentifierUtility } from '../../../components/hooks/useSafeChains'; import Logger from '../../../util/Logger'; -import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectNetworkConfigurations, + selectIsAllNetworks, +} from '../../../selectors/networkController'; import { NetworkConfiguration, RpcEndpointType, @@ -85,6 +91,7 @@ const NetworkModals = (props: NetworkProps) => { const [showDetails, setShowDetails] = React.useState(false); const [networkAdded, setNetworkAdded] = React.useState(false); const [showCheckNetwork, setShowCheckNetwork] = React.useState(false); + const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); const [alerts, setAlerts] = React.useState< { alertError: string; @@ -96,6 +103,7 @@ const NetworkModals = (props: NetworkProps) => { const isCustomNetwork = true; const showDetailsModal = () => setShowDetails(!showDetails); const showCheckNetworkModal = () => setShowCheckNetwork(!showCheckNetwork); + const isAllNetworks = useSelector(selectIsAllNetworks); const { colors } = useTheme(); const styles = createNetworkModalStyles(colors); @@ -107,6 +115,30 @@ const NetworkModals = (props: NetworkProps) => { return true; }; + const customNetworkInformation = { + chainId, + blockExplorerUrl, + chainName: nickname, + rpcUrl, + icon: imageUrl, + ticker, + alerts, + }; + + const onUpdateNetworkFilter = useCallback(() => { + const { PreferencesController } = Engine.context; + if (!isAllNetworks) { + PreferencesController.setTokenNetworkFilter({ + [customNetworkInformation.chainId]: true, + }); + } else { + PreferencesController.setTokenNetworkFilter({ + ...tokenNetworkFilter, + [customNetworkInformation.chainId]: true, + }); + } + }, [customNetworkInformation.chainId, isAllNetworks, tokenNetworkFilter]); + const addNetwork = async () => { const isValidUrl = validateRpcUrl(rpcUrl); if (showPopularNetworkModal) { @@ -170,16 +202,6 @@ const NetworkModals = (props: NetworkProps) => { selectNetworkConfigurations, ); - const customNetworkInformation = { - chainId, - blockExplorerUrl, - chainName: nickname, - rpcUrl, - icon: imageUrl, - ticker, - alerts, - }; - const checkNetwork = useCallback(async () => { if (useSafeChainsListValidation) { const alertsNetwork = await checkSafeNetwork( @@ -243,6 +265,7 @@ const NetworkModals = (props: NetworkProps) => { } if (networkClientId) { + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); } @@ -268,7 +291,7 @@ const NetworkModals = (props: NetworkProps) => { const { networkClientId } = updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] ?? {}; - + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); }; @@ -337,6 +360,7 @@ const NetworkModals = (props: NetworkProps) => { addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? {}; + onUpdateNetworkFilter(); NetworkController.setActiveNetwork(networkClientId); } onClose(); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx index 391fe7e3b89..a01c0814d2f 100644 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ b/app/components/UI/PaymentRequest/index.test.tsx @@ -39,6 +39,11 @@ const initialState = { }, }, tokens: [], + allTokens: { + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, }, NetworkController: { provider: { @@ -50,7 +55,7 @@ const initialState = { ...MOCK_ACCOUNTS_CONTROLLER_STATE, internalAccounts: { ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, - selectedAccount: {}, + selectedAccount: '30786334-3935-4563-b064-363339643939', }, }, TokenListController: { diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 53621dcd7e0..650343c3958 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -57,6 +57,23 @@ jest.mock('../../../../../selectors/currencyRateController.ts', () => ({ selectCurrentCurrency: jest.fn(() => 'USD'), })); +// Add mock for multichain selectors +jest.mock('../../../../../selectors/multichain', () => ({ + selectAccountTokensAcrossChains: jest.fn(() => ({ + '0x1': [ + { + address: '0x0', + symbol: 'ETH', + decimals: 18, + balance: '1.5', + balanceFiat: '$3000', + isNative: true, + isETH: true, + }, + ], + })), +})); + const mockBalanceBN = toWei('1.5'); // 1.5 ETH const mockPooledStakingContractService: PooledStakingContract = { diff --git a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx index 02c92e51339..b2f83588cf4 100644 --- a/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/UnstakeInputView/UnstakeInputView.test.tsx @@ -10,6 +10,22 @@ import { MOCK_STAKED_ETH_ASSET, } from '../../__mocks__/mockData'; +jest.mock('../../../../../selectors/multichain', () => ({ + selectAccountTokensAcrossChains: jest.fn(() => ({ + '0x1': [ + { + address: '0x0', + symbol: 'ETH', + decimals: 18, + balance: '1.5', + balanceFiat: '$3000', + isNative: true, + isETH: true, + }, + ], + })), +})); + function render(Component: React.ComponentType) { return renderScreen( Component, diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx index 09abd65ab7f..39e0dd2084b 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBalance.test.tsx @@ -12,6 +12,8 @@ import { } from '../../__mocks__/mockData'; import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; import { backgroundState } from '../../../../../util/test/initial-root-state'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../../../util/networks'; const MOCK_ADDRESS_1 = '0x0'; @@ -130,6 +132,15 @@ describe('StakingBalance', () => { expect(toJSON()).toMatchSnapshot(); }); + it('should match the snapshot when portfolio view is enabled ', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + const { toJSON } = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(toJSON()).toMatchSnapshot(); + }); + it('redirects to StakeInputView on stake button click', () => { const { getByText } = renderWithProvider( , diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index c87de236d68..8095e5ad753 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -465,3 +465,471 @@ exports[`StakingBalance render matches snapshot 1`] = ` `; + +exports[`StakingBalance should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + + + + + + + + + + + Staked Ethereum + + + + + + + + + + + Unstaking 0.0010 ETH in progress. Come back in a few days to claim it. + + + + + + + + + + You can claim 0.00214 ETH. Once claimed, you'll get ETH back in your wallet. + + + + Claim + ETH + + + + + + + + Unstake + + + + + Stake more + + + + + +`; diff --git a/app/components/UI/Stake/hooks/useStakingChain.test.tsx b/app/components/UI/Stake/hooks/useStakingChain.test.tsx index c29df592c0a..a53380a458b 100644 --- a/app/components/UI/Stake/hooks/useStakingChain.test.tsx +++ b/app/components/UI/Stake/hooks/useStakingChain.test.tsx @@ -1,8 +1,9 @@ import { backgroundState } from '../../../../util/test/initial-root-state'; import { renderHookWithProvider } from '../../../../util/test/renderWithProvider'; import { toHex } from '@metamask/controller-utils'; -import useStakingChain from './useStakingChain'; +import useStakingChain, { useStakingChainByChainId } from './useStakingChain'; import { mockNetworkState } from '../../../../util/test/network'; +import { Hex } from '@metamask/utils'; const buildStateWithNetwork = (chainId: string, nickname: string) => ({ engine: { @@ -57,3 +58,41 @@ describe('useStakingChain', () => { }); }); }); + +describe('useStakingChainByChainId', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('returns true for a supported chainId (mainnet)', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('1')), + ); + expect(result.current.isStakingSupportedChain).toBe(true); + }); + + it('returns true for a supported chainId (Holesky)', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('17000')), + ); + expect(result.current.isStakingSupportedChain).toBe(true); + }); + + it('returns false for an unsupported chainId', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId(toHex('11')), + ); + expect(result.current.isStakingSupportedChain).toBe(false); + }); + + it('handles invalid chainId gracefully', () => { + const { result } = renderHookWithProvider(() => + useStakingChainByChainId('invalid-chain-id' as Hex), + ); + expect(result.current.isStakingSupportedChain).toBe(false); + }); +}); diff --git a/app/components/UI/Stake/hooks/useStakingChain.ts b/app/components/UI/Stake/hooks/useStakingChain.ts index d5da33c504e..934d7901023 100644 --- a/app/components/UI/Stake/hooks/useStakingChain.ts +++ b/app/components/UI/Stake/hooks/useStakingChain.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { useSelector } from 'react-redux'; import { getDecimalChainId } from '../../../../util/networks'; import { selectChainId } from '../../../../selectors/networkController'; @@ -13,4 +14,12 @@ const useStakingChain = () => { }; }; +export const useStakingChainByChainId = (chainId: Hex) => { + const isStakingSupportedChain = isSupportedChain(getDecimalChainId(chainId)); + + return { + isStakingSupportedChain, + }; +}; + export default useStakingChain; diff --git a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx index 43257ab4d1e..92e72f96ab2 100644 --- a/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx +++ b/app/components/UI/Tokens/TokenList/PortfolioBalance/index.tsx @@ -110,14 +110,14 @@ export const PortfolioBalance = () => { let total; if (isOriginalNativeTokenSymbol) { - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { total = totalFiatBalance ?? 0; } else { const tokenFiatTotal = balance?.tokenFiat ?? 0; const ethFiatTotal = balance?.ethFiat ?? 0; total = tokenFiatTotal + ethFiatTotal; } - } else if (isPortfolioViewEnabled) { + } else if (isPortfolioViewEnabled()) { total = totalTokenFiat ?? 0; } else { total = balance?.tokenFiat ?? 0; @@ -175,7 +175,7 @@ export const PortfolioBalance = () => { return null; } - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { return ( { const navigation = useNavigation(); const { colors } = useTheme(); - const { data: tokenBalances } = useTokenBalancesController(); + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const { data: selectedChainTokenBalance } = useTokenBalancesController(); const { type } = useSelector(selectProviderConfig); - const chainId = useSelector(selectChainId); + const selectedChainId = useSelector(selectChainId); + const chainId = isPortfolioViewEnabled() + ? (asset.chainId as Hex) + : selectedChainId; const ticker = useSelector(selectTicker); const isOriginalNativeTokenSymbol = useIsOriginalNativeTokenSymbol( chainId, ticker, type, ); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const currentCurrency = useSelector(selectCurrentCurrency); - const conversionRate = useSelector(selectConversionRate); const networkName = useSelector(selectNetworkName); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, ); + const currentCurrency = useSelector(selectCurrentCurrency); + const networkConfigurations = useSelector(selectNetworkConfigurations); + const showFiatOnTestnets = useSelector(selectShowFiatInTestnets); + + // single chain + const singleTokenExchangeRates = useSelector(selectContractExchangeRates); + const singleTokenConversionRate = useSelector(selectConversionRate); + + // multi chain + const multiChainTokenBalance = useSelector(selectTokensBalances); + const multiChainMarketData = useSelector(selectTokenMarketData); + const multiChainCurrencyRates = useSelector(selectCurrencyRates); const styles = createStyles(colors); const itemAddress = safeToChecksumAddress(asset.address); + // Choose values based on multichain or legacy + const exchangeRates = isPortfolioViewEnabled() + ? multiChainMarketData?.[chainId as Hex] + : singleTokenExchangeRates; + const tokenBalances = isPortfolioViewEnabled() + ? multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId as Hex + ] + : selectedChainTokenBalance; + const nativeCurrency = + networkConfigurations?.[chainId as Hex]?.nativeCurrency; + + const conversionRate = isPortfolioViewEnabled() + ? multiChainCurrencyRates?.[nativeCurrency]?.conversionRate || 0 + : singleTokenConversionRate; + const { balanceFiat, balanceValueFormatted } = deriveBalanceFromAssetMarketDetails( asset, - tokenExchangeRates, - tokenBalances, - conversionRate, - currentCurrency, + exchangeRates || {}, + tokenBalances || {}, + conversionRate || 0, + currentCurrency || '', ); - const pricePercentChange1d = itemAddress - ? tokenExchangeRates?.[itemAddress as `0x${string}`]?.pricePercentChange1d - : tokenExchangeRates?.[zeroAddress() as Hex]?.pricePercentChange1d; + let pricePercentChange1d: number; + + if (isPortfolioViewEnabled()) { + const tokenPercentageChange = asset.address + ? multiChainMarketData?.[chainId as Hex]?.[asset.address as Hex] + ?.pricePercentChange1d + : 0; + + pricePercentChange1d = asset.isNative + ? multiChainMarketData?.[chainId as Hex]?.[zeroAddress() as Hex] + ?.pricePercentChange1d + : tokenPercentageChange; + } else { + pricePercentChange1d = itemAddress + ? exchangeRates?.[itemAddress as Hex]?.pricePercentChange1d + : exchangeRates?.[zeroAddress() as Hex]?.pricePercentChange1d; + } // render balances according to primary currency let mainBalance; let secondaryBalance; + const shouldNotShowBalanceOnTestnets = + isTestNet(chainId) && !showFiatOnTestnets; // Set main and secondary balances based on the primary currency and asset type. if (primaryCurrency === 'ETH') { // Default to displaying the formatted balance value and its fiat equivalent. mainBalance = balanceValueFormatted; secondaryBalance = balanceFiat; - // For ETH as a native currency, adjust display based on network safety. if (asset.isETH) { // Main balance always shows the formatted balance value for ETH. mainBalance = balanceValueFormatted; // Display fiat value as secondary balance only for original native tokens on safe networks. - secondaryBalance = isOriginalNativeTokenSymbol ? balanceFiat : null; - } - } else { - // For non-ETH currencies, determine balances based on the presence of fiat value. - mainBalance = !balanceFiat ? balanceValueFormatted : balanceFiat; - secondaryBalance = !balanceFiat ? balanceFiat : balanceValueFormatted; - - // Adjust balances for native currencies in non-ETH scenarios. - if (asset.isETH) { - // Main balance logic: Show crypto value if fiat is absent or fiat value on safe networks. - if (!balanceFiat) { - mainBalance = balanceValueFormatted; // Show crypto value if fiat is not preferred - } else if (isOriginalNativeTokenSymbol) { - mainBalance = balanceFiat; // Show fiat value if it's a safe network + if (isPortfolioViewEnabled()) { + secondaryBalance = shouldNotShowBalanceOnTestnets + ? undefined + : balanceFiat; } else { - mainBalance = ''; // Otherwise, set to an empty string + secondaryBalance = isOriginalNativeTokenSymbol ? balanceFiat : null; } - // Secondary balance mirrors the main balance logic for consistency. - secondaryBalance = !balanceFiat ? balanceFiat : balanceValueFormatted; + } + } else { + secondaryBalance = balanceValueFormatted; + if (shouldNotShowBalanceOnTestnets && !balanceFiat) { + mainBalance = undefined; + } else { + mainBalance = + balanceFiat ?? strings('wallet.unable_to_find_conversion_rate'); } } @@ -154,38 +211,108 @@ export const TokenListItem = ({ const isMainnet = isMainnetByChainId(chainId); const isLineaMainnet = isLineaMainnetByChainId(chainId); - const { isStakingSupportedChain } = useStakingChain(); + const { isStakingSupportedChain } = useStakingChainByChainId(chainId); - const NetworkBadgeSource = () => { - if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); + const networkBadgeSource = useCallback( + (currentChainId: Hex) => { + if (!isPortfolioViewEnabled()) { + if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); + if (isMainnet) return images.ETHEREUM; - if (isMainnet) return images.ETHEREUM; + if (isLineaMainnet) return images['LINEA-MAINNET']; - if (isLineaMainnet) return images['LINEA-MAINNET']; + if (CustomNetworkImgMapping[chainId]) { + return CustomNetworkImgMapping[chainId]; + } - if (CustomNetworkImgMapping[chainId]) { - return CustomNetworkImgMapping[chainId]; - } + return ticker ? images[ticker] : undefined; + } + if (isTestNet(currentChainId)) + return getTestNetImageByChainId(currentChainId); + const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as + | { + imageSource: string; + } + | undefined; - return ticker ? images[ticker] : 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 (customNetworkImg) { + return customNetworkImg; + } + }, + [chainId, isLineaMainnet, isMainnet, ticker], + ); const onItemPress = (token: TokenI) => { // if the asset is staked, navigate to the native asset details if (asset.isStaked) { - return navigation.navigate('Asset', { ...token.nativeAsset }); + return navigation.navigate('Asset', { + ...token.nativeAsset, + }); } navigation.navigate('Asset', { ...token, }); }; + const renderNetworkAvatar = useCallback(() => { + if (!isPortfolioViewEnabled() && asset.isETH) { + return ; + } + + if (isPortfolioViewEnabled() && asset.isNative) { + return ( + + ); + } + + return ( + + ); + }, [ + asset.ticker, + asset.isETH, + asset.image, + asset.symbol, + asset.isNative, + styles.ethLogo, + chainId, + ]); + return ( } > - {asset.isETH ? ( - - ) : ( - - )} + {renderNetworkAvatar()} diff --git a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx index 82eeffc8ddd..0a8871988c9 100644 --- a/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx +++ b/app/components/UI/Tokens/TokensBottomSheet/TokenFilterBottomSheet.tsx @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { selectChainId, selectNetworkConfigurations, + selectIsAllNetworks, } from '../../../../selectors/networkController'; import { selectTokenNetworkFilter } from '../../../../selectors/preferencesController'; import BottomSheet, { @@ -33,6 +34,7 @@ const TokenFilterBottomSheet = () => { const chainId = useSelector(selectChainId); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); + const isAllNetworks = useSelector(selectIsAllNetworks); const allNetworksEnabled = useMemo( () => enableAllNetworksFilter(allNetworks), [allNetworks], @@ -59,8 +61,6 @@ const TokenFilterBottomSheet = () => { const isCurrentNetwork = Boolean( tokenNetworkFilter[chainId] && Object.keys(tokenNetworkFilter).length === 1, ); - const isAllNetworks = - Object.keys(tokenNetworkFilter).length === Object.keys(allNetworks).length; return ( diff --git a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap index b1fa6c4ca1f..773a9b7e4e1 100644 --- a/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/Tokens/__snapshots__/index.test.tsx.snap @@ -1,5 +1,1766 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Tokens Portfolio View should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + + + Amount + + + + + + + + + + + + + + + + + + + + + + Ethereum Main Network + + + + + + + + + + + + + + + + You don't have any tokens! + + + + + + + + + + + + + +`; + +exports[`Tokens render matches snapshot 1`] = ` + + + + + + + + + + + + + Amount + + + + + + + + + + + + + + + + + + + + + Sort by + + + + + + + Import + + + + + } + data={ + [ + { + "address": "0x0", + "balanceFiat": "< $0.01", + "decimals": 18, + "iconUrl": "", + "isETH": true, + "isStaked": false, + "name": "Ethereum", + "symbol": "ETH", + "tokenFiatAmount": NaN, + }, + { + "address": "0x01", + "balanceFiat": "$0", + "decimals": 18, + "iconUrl": "", + "name": "Bat", + "symbol": "BAT", + "tokenFiatAmount": NaN, + }, + ] + } + getItem={[Function]} + getItemCount={[Function]} + keyExtractor={[Function]} + onContentSizeChange={[Function]} + onLayout={[Function]} + onMomentumScrollBegin={[Function]} + onMomentumScrollEnd={[Function]} + onScroll={[Function]} + onScrollBeginDrag={[Function]} + onScrollEndDrag={[Function]} + refreshControl={ + + } + removeClippedSubviews={false} + renderItem={[Function]} + scrollEventThrottle={50} + stickyHeaderIndices={[]} + testID="token-list" + viewabilityConfigCallbackPairs={[]} + > + + + + + + + + + + + + + + + + + + + + + + + + + Ethereum + + + + • + + Earn + + + + + + + + + + + + + ETH + + + < $0.01 + + + + + + + + + + + + + + + + + + + + + + + + Bat + + + + + + + + + + < 0.00001 BAT + + + < $0.01 + + + + + + + + + + Don't see your token? + + + + Import tokens + + + + + + + + + + + + + + + + + + +`; + exports[`Tokens should hide zero balance tokens when setting is on 1`] = ` ({ showSimpleNotification: jest.fn(() => Promise.resolve()), })); +const selectedAddress = '0x123'; + jest.mock('./TokensBottomSheet', () => ({ createTokensBottomSheetNavDetails: jest.fn(() => ['BottomSheetScreen', {}]), })); @@ -53,11 +57,53 @@ jest.mock('../../../core/Engine', () => ({ }), findNetworkClientIdByChainId: () => 'mainnet', }, + AccountsController: { + state: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: selectedAddress, + }, + }, + }, + }, + }, }, })); -const selectedAddress = '0x123'; - +const mockTokens = { + '0x1': { + [selectedAddress]: [ + { + name: 'Ethereum', + symbol: 'ETH', + address: '0x0', + decimals: 18, + isETH: true, + isStaked: false, + 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: '', + }, + ], + }, +}; const initialState = { engine: { backgroundState: { @@ -80,7 +126,7 @@ const initialState = { address: '0x0', decimals: 18, isETH: true, - + isStaked: false, balanceFiat: '< $0.01', iconUrl: '', }, @@ -101,6 +147,38 @@ const initialState = { iconUrl: '', }, ], + allTokens: { + '0x1': { + [selectedAddress]: [ + { + 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: '', + }, + ], + }, + }, detectedTokens: [], }, TokenRatesController: { @@ -172,11 +250,10 @@ jest.mock('../../UI/Stake/hooks/useStakingEligibility', () => ({ })), })); -const mockIsPortfolioViewEnabled = jest.fn(); - -jest.mock('../../../util/networks', () => ({ - ...jest.requireActual('../../../util/networks'), - isPortfolioViewEnabled: mockIsPortfolioViewEnabled, +jest.mock('../Stake/hooks/useStakingChain', () => ({ + useStakingChainByChainId: () => ({ + isStakingSupportedChain: true, + }), })); const Stack = createStackNavigator(); @@ -199,7 +276,7 @@ const renderComponent = (state: any = {}) => describe('Tokens', () => { beforeEach(() => { - mockIsPortfolioViewEnabled.mockReturnValue(false); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); }); afterEach(() => { @@ -212,6 +289,11 @@ describe('Tokens', () => { expect(toJSON()).toMatchSnapshot(); }); + it('render matches snapshot', () => { + const { toJSON } = renderComponent(initialState); + expect(toJSON()).toMatchSnapshot(); + }); + it('should hide zero balance tokens when setting is on', async () => { const { toJSON, getByText, queryByText } = renderComponent(initialState); @@ -264,6 +346,7 @@ describe('Tokens', () => { ...backgroundState, TokensController: { detectedTokens: [], + allTokens: mockTokens, tokens: [ { name: 'Link', @@ -277,7 +360,7 @@ describe('Tokens', () => { }, TokenRatesController: { marketData: { - 0x1: { + '0x1': { '0x02': undefined, }, }, @@ -299,6 +382,16 @@ describe('Tokens', () => { }, }, }, + state: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: selectedAddress, + }, + }, + }, + }, }, TokenBalancesController: { tokenBalances: { @@ -389,20 +482,23 @@ describe('Tokens', () => { }, ); - await waitFor(() => { - expect( - Engine.context.TokenDetectionController.detectTokens, - ).toHaveBeenCalled(); - expect( - Engine.context.AccountTrackerController.refresh, - ).toHaveBeenCalled(); - expect( - Engine.context.CurrencyRateController.updateExchangeRate, - ).toHaveBeenCalled(); - expect( - Engine.context.TokenRatesController.updateExchangeRatesByChainId, - ).toHaveBeenCalled(); - }); + await waitFor( + () => { + expect( + Engine.context.TokenDetectionController.detectTokens, + ).toHaveBeenCalled(); + expect( + Engine.context.AccountTrackerController.refresh, + ).toHaveBeenCalled(); + expect( + Engine.context.CurrencyRateController.updateExchangeRate, + ).toHaveBeenCalled(); + expect( + Engine.context.TokenRatesController.updateExchangeRatesByChainId, + ).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); }); it('triggers bottom sheet when sort controls are pressed', async () => { @@ -456,4 +552,149 @@ describe('Tokens', () => { }); }); }); + + it('calls onRefresh and updates state', async () => { + const { getByTestId } = renderComponent(initialState); + + fireEvent( + getByTestId(WalletViewSelectorsIDs.TOKENS_CONTAINER_LIST), + 'refresh', + { + refreshing: true, + }, + ); + + await waitFor(() => { + expect( + Engine.context.TokenDetectionController.detectTokens, + ).toHaveBeenCalled(); + expect( + Engine.context.AccountTrackerController.refresh, + ).toHaveBeenCalled(); + expect( + Engine.context.CurrencyRateController.updateExchangeRate, + ).toHaveBeenCalled(); + expect( + Engine.context.TokenRatesController.updateExchangeRatesByChainId, + ).toHaveBeenCalled(); + }); + }); + + it('hides zero balance tokens when hideZeroBalanceTokens is enabled', () => { + const { queryByText } = renderComponent(initialState); + + expect(queryByText('Link')).toBeNull(); // Zero balance token should not be visible + }); + + it('triggers sort controls when sort button is pressed', async () => { + const { getByTestId } = renderComponent(initialState); + + fireEvent.press(getByTestId(WalletViewSelectorsIDs.SORT_BY)); + + await waitFor(() => { + expect(createTokensBottomSheetNavDetails).toHaveBeenCalledWith({}); + }); + }); + + describe('Portfolio View', () => { + beforeEach(() => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + }); + + it('should match the snapshot when portfolio view is enabled ', () => { + const { toJSON } = renderComponent(initialState); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should handle network filtering correctly', () => { + const multiNetworkState = { + ...initialState, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + PreferencesController: { + selectedAddress, + tokenSortConfig: { key: 'symbol', order: 'asc' }, + tokenNetworkFilter: { + '0x1': true, + '0x89': false, + }, + }, + }, + selectedAccountTokensChains: { + '0x1': [ + { + address: '0x123', + symbol: 'ETH', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: true, + chainId: '0x1', + }, + ], + '0x89': [ + { + address: '0x456', + symbol: 'MATIC', + decimals: 18, + balance: '2000000000000000000', + balanceFiat: '$200', + isNative: true, + chainId: '0x89', + }, + ], + }, + }, + }; + + const { queryByText } = renderComponent(multiNetworkState); + expect(queryByText('ETH')).toBeDefined(); + expect(queryByText('MATIC')).toBeNull(); + }); + + it('should filter zero balance tokens when hideZeroBalanceTokens is enabled', () => { + const stateWithZeroBalances = { + ...initialState, + settings: { + hideZeroBalanceTokens: true, + }, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + TokensController: { + allTokens: { + '0x1': { + [selectedAddress]: [ + { + address: '0x123', + symbol: 'ZERO', + decimals: 18, + balance: '0', + balanceFiat: '$0', + isNative: false, + chainId: '0x1', + }, + { + address: '0x456', + symbol: 'NON_ZERO', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0x1', + }, + ], + }, + }, + }, + }, + }, + }; + + const { queryByText } = renderComponent(stateWithZeroBalances); + expect(queryByText('ZERO')).toBeNull(); + expect(queryByText('NON_ZERO')).toBeDefined(); + }); + }); }); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 0c28821fc34..b4d895b45f9 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -1,8 +1,11 @@ -import React, { useRef, useState, LegacyRef, useMemo } from 'react'; +import React, { useRef, useState, LegacyRef, useMemo, useEffect } from 'react'; +import { Hex } from '@metamask/utils'; import { View, Text } from 'react-native'; import ActionSheet from '@metamask/react-native-actionsheet'; import { useSelector } from 'react-redux'; import useTokenBalancesController from '../../hooks/useTokenBalancesController/useTokenBalancesController'; +import { selectTokensBalances } from '../../../selectors/tokenBalancesController'; +import { selectSelectedInternalAccountAddress } from '../../../selectors/accountsController'; import { useTheme } from '../../../util/theme'; import { useMetrics } from '../../../components/hooks/useMetrics'; import Engine from '../../../core/Engine'; @@ -11,12 +14,13 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import Logger from '../../../util/Logger'; import { selectChainId, + selectIsAllNetworks, selectNetworkConfigurations, } from '../../../selectors/networkController'; import { getDecimalChainId, - isPortfolioViewEnabled, isTestNet, + isPortfolioViewEnabled, } from '../../../util/networks'; import { isZero } from '../../../util/lodash'; import createStyles from './styles'; @@ -33,10 +37,14 @@ import { deriveBalanceFromAssetMarketDetails, sortAssets } from './util'; import { useNavigation } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { RootState } from '../../../reducers'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; +import { + selectContractExchangeRates, + selectTokenMarketData, +} from '../../../selectors/tokenRatesController'; import { selectConversionRate, selectCurrentCurrency, + selectCurrencyRates, } from '../../../selectors/currencyRateController'; import { createTokenBottomSheetFilterNavDetails, @@ -45,7 +53,8 @@ import { import ButtonBase from '../../../component-library/components/Buttons/Button/foundation/ButtonBase'; import { selectNetworkName } from '../../../selectors/networkInfos'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; -import { Hex } from '@metamask/utils'; +import { selectAccountTokensAcrossChains } from '../../../selectors/multichain'; +import { filterAssets } from './util/filterAssets'; // this will be imported from TokenRatesController when it is exported from there // PR: https://github.com/MetaMask/core/pull/4622 @@ -88,7 +97,7 @@ const Tokens: React.FC = ({ tokens }) => { const { data: tokenBalances } = useTokenBalancesController(); const tokenSortConfig = useSelector(selectTokenSortConfig); const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); - const chainId = useSelector(selectChainId); + const selectedChainId = useSelector(selectChainId); const networkConfigurationsByChainId = useSelector( selectNetworkConfigurations, ); @@ -100,6 +109,7 @@ const Tokens: React.FC = ({ tokens }) => { const currentCurrency = useSelector(selectCurrentCurrency); const conversionRate = useSelector(selectConversionRate); const networkName = useSelector(selectNetworkName); + const currentChainId = useSelector(selectChainId); const nativeCurrencies = [ ...new Set( Object.values(networkConfigurationsByChainId).map( @@ -107,15 +117,110 @@ const Tokens: React.FC = ({ tokens }) => { ), ), ]; + const selectedAccountTokensChains = useSelector( + selectAccountTokensAcrossChains, + ); const actionSheet = useRef(); const [tokenToRemove, setTokenToRemove] = useState(); const [refreshing, setRefreshing] = useState(false); const [isAddTokenEnabled, setIsAddTokenEnabled] = useState(true); + const isAllNetworks = useSelector(selectIsAllNetworks); + + // multi chain + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const multiChainMarketData = useSelector(selectTokenMarketData); + const multiChainTokenBalance = useSelector(selectTokensBalances); + const multiChainCurrencyRates = useSelector(selectCurrencyRates); const styles = createStyles(colors); - const tokensList = useMemo(() => { + const tokensList = useMemo((): TokenI[] => { + if (isPortfolioViewEnabled()) { + // MultiChain implementation + const allTokens = Object.values( + selectedAccountTokensChains, + ).flat() as TokenI[]; + + // First filter zero balance tokens if setting is enabled + const tokensToDisplay = hideZeroBalanceTokens + ? allTokens.filter( + (curToken) => + !isZero(curToken.balance) || + curToken.isNative || + curToken.isStaked, + ) + : allTokens; + + // Then apply network filters + const filteredAssets = filterAssets(tokensToDisplay, [ + { + key: 'chainId', + opts: tokenNetworkFilter, + filterCallback: 'inclusive', + }, + ]); + + const { nativeTokens, nonNativeTokens } = filteredAssets.reduce<{ + nativeTokens: TokenI[]; + nonNativeTokens: TokenI[]; + }>( + ( + acc: { nativeTokens: TokenI[]; nonNativeTokens: TokenI[] }, + currToken: unknown, + ) => { + if ( + isTestNet((currToken as TokenI & { chainId: string }).chainId) && + !isTestNet(currentChainId) + ) { + return acc; + } + if ((currToken as TokenI).isNative) { + acc.nativeTokens.push(currToken as TokenI); + } else { + acc.nonNativeTokens.push(currToken as TokenI); + } + return acc; + }, + { nativeTokens: [], nonNativeTokens: [] }, + ); + + const assets = [...nativeTokens, ...nonNativeTokens]; + + // Calculate fiat balances for tokens + const tokenFiatBalances = assets.map((token) => { + const chainId = token.chainId as Hex; + const multiChainExchangeRates = multiChainMarketData?.[chainId]; + const multiChainTokenBalances = + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId + ]; + const nativeCurrency = + networkConfigurationsByChainId[chainId].nativeCurrency; + const multiChainConversionRate = + multiChainCurrencyRates?.[nativeCurrency]?.conversionRate || 0; + + return token.isETH || token.isNative + ? parseFloat(token.balance) * multiChainConversionRate + : deriveBalanceFromAssetMarketDetails( + token, + multiChainExchangeRates || {}, + multiChainTokenBalances || {}, + multiChainConversionRate || 0, + currentCurrency || '', + ).balanceFiatCalculation; + }); + + const tokensWithBalances = assets.map((token, i) => ({ + ...token, + tokenFiatAmount: tokenFiatBalances[i], + })); + + return sortAssets(tokensWithBalances, tokenSortConfig); + } + // Previous implementation // Filter tokens based on hideZeroBalanceTokens flag const tokensToDisplay = hideZeroBalanceTokens ? tokens.filter( @@ -130,10 +235,10 @@ const Tokens: React.FC = ({ tokens }) => { ? parseFloat(asset.balance) * conversionRate : deriveBalanceFromAssetMarketDetails( asset, - tokenExchangeRates, - tokenBalances, - conversionRate, - currentCurrency, + tokenExchangeRates || {}, + tokenBalances || {}, + conversionRate || 0, + currentCurrency || '', ).balanceFiatCalculation, ) : []; @@ -157,6 +262,15 @@ const Tokens: React.FC = ({ tokens }) => { tokenExchangeRates, tokenSortConfig, tokens, + // Dependencies for multichain implementation + selectedAccountTokensChains, + tokenNetworkFilter, + currentChainId, + multiChainCurrencyRates, + multiChainMarketData, + multiChainTokenBalance, + networkConfigurationsByChainId, + selectedInternalAccountAddress, ]); const showRemoveMenu = (token: TokenI) => { @@ -184,17 +298,18 @@ const Tokens: React.FC = ({ tokens }) => { CurrencyRateController, TokenRatesController, } = Engine.context; + const actions = [ TokenDetectionController.detectTokens({ - chainIds: isPortfolioViewEnabled + chainIds: isPortfolioViewEnabled() ? (Object.keys(networkConfigurationsByChainId) as Hex[]) - : [chainId], + : [selectedChainId], }), AccountTrackerController.refresh(), CurrencyRateController.updateExchangeRate(nativeCurrencies), - ...(isPortfolioViewEnabled + ...(isPortfolioViewEnabled() ? Object.values(networkConfigurationsByChainId) - : [networkConfigurationsByChainId[chainId]] + : [networkConfigurationsByChainId[selectedChainId]] ).map((network) => TokenRatesController.updateExchangeRatesByChainId({ chainId: network.chainId, @@ -210,11 +325,18 @@ const Tokens: React.FC = ({ tokens }) => { }; const removeToken = async () => { - const { TokensController } = Engine.context; + const { TokensController, NetworkController } = Engine.context; + const chainId = isPortfolioViewEnabled() + ? tokenToRemove?.chainId + : selectedChainId; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); const tokenAddress = tokenToRemove?.address || ''; + const symbol = tokenToRemove?.symbol; try { - await TokensController.ignoreTokens([tokenAddress]); + await TokensController.ignoreTokens([tokenAddress], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, @@ -230,7 +352,7 @@ const Tokens: React.FC = ({ tokens }) => { token_standard: 'ERC20', asset_type: 'token', tokens: [`${symbol} - ${tokenAddress}`], - chain_id: getDecimalChainId(chainId), + chain_id: getDecimalChainId(selectedChainId), }) .build(), ); @@ -246,7 +368,7 @@ const Tokens: React.FC = ({ tokens }) => { createEventBuilder(MetaMetricsEvents.TOKEN_IMPORT_CLICKED) .addProperties({ source: 'manual', - chain_id: getDecimalChainId(chainId), + chain_id: getDecimalChainId(selectedChainId), }) .build(), ); @@ -256,26 +378,40 @@ const Tokens: React.FC = ({ tokens }) => { const onActionSheetPress = (index: number) => index === 0 ? removeToken() : null; + useEffect(() => { + const { PreferencesController } = Engine.context; + if (isTestNet(currentChainId)) { + PreferencesController.setTokenNetworkFilter({ + [currentChainId]: true, + }); + } + }, [currentChainId]); + return ( - {isPortfolioViewEnabled ? ( + {isPortfolioViewEnabled() ? ( - {tokenNetworkFilter[chainId] - ? networkName ?? strings('wallet.current_network') - : strings('wallet.all_networks')} + {isAllNetworks + ? strings('wallet.all_networks') + : networkName ?? strings('wallet.current_network')} } + isDisabled={isTestNet(currentChainId)} onPress={showFilterControls} endIconName={IconName.ArrowDown} - style={styles.controlButton} - disabled={isTestNet(chainId)} + style={ + isTestNet(currentChainId) + ? styles.controlButtonDisabled + : styles.controlButton + } + disabled={isTestNet(currentChainId)} /> marginRight: 5, maxWidth: '60%', }, + controlButtonDisabled: { + backgroundColor: colors.background.default, + borderColor: colors.border.default, + borderStyle: 'solid', + borderWidth: 1, + marginLeft: 5, + marginRight: 5, + maxWidth: '60%', + opacity: 0.5, + }, controlButtonText: { color: colors.text.default, }, diff --git a/app/components/UI/Tokens/types.ts b/app/components/UI/Tokens/types.ts index 90fa5ad8b49..a1efce4ff09 100644 --- a/app/components/UI/Tokens/types.ts +++ b/app/components/UI/Tokens/types.ts @@ -25,4 +25,5 @@ export interface TokenI { nativeAsset?: TokenI | undefined; chainId?: string; isNative?: boolean; + ticker?: string; } diff --git a/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts b/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts index 6f9d642b4f2..5f23998dae1 100644 --- a/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts +++ b/app/components/UI/Tokens/util/deriveBalanceFromAssetMarketDetails.ts @@ -42,8 +42,10 @@ export const deriveBalanceFromAssetMarketDetails = ( balanceValueFormatted: TOKEN_BALANCE_LOADING, }; } - - const balanceValueFormatted = `${balance} ${asset.symbol}`; + let balanceValueFormatted = `${balance} ${asset.symbol}`; + if (asset.isNative) { + balanceValueFormatted = `${balance} ${asset.ticker}`; + } if (!conversionRate) return { @@ -53,10 +55,12 @@ export const deriveBalanceFromAssetMarketDetails = ( if (!tokenMarketData || tokenMarketData === TOKEN_RATE_UNDEFINED) return { - balanceFiat: asset.isETH ? asset.balanceFiat : TOKEN_RATE_UNDEFINED, + balanceFiat: + asset.isETH || asset.isNative + ? asset.balanceFiat + : TOKEN_RATE_UNDEFINED, balanceValueFormatted, }; - const balanceFiatCalculation = Number( asset.balanceFiat || balanceToFiatNumber(balance, conversionRate, tokenMarketData.price), diff --git a/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts b/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts new file mode 100644 index 00000000000..0a41d9c6db7 --- /dev/null +++ b/app/components/UI/Tokens/util/enableAllNetworksFilter.test.ts @@ -0,0 +1,164 @@ +import { RpcEndpointType } from '@metamask/network-controller'; +import { NETWORK_CHAIN_ID } from '../../../../util/networks/customNetworks'; +import { + enableAllNetworksFilter, + KnownNetworkConfigurations, +} from './enableAllNetworksFilter'; + +type TestNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0x1' | '0x89' +>; + +type FlareTestNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0xe' | '0x13' +>; + +type MultiNetworkConfigurations = Pick< + KnownNetworkConfigurations, + '0x1' | '0x89' | typeof NETWORK_CHAIN_ID.BASE +>; + +describe('enableAllNetworksFilter', () => { + it('should create a record with all network chain IDs mapped to true', () => { + const mockNetworks: TestNetworkConfigurations = { + [NETWORK_CHAIN_ID.MAINNET]: { + chainId: NETWORK_CHAIN_ID.MAINNET, + name: 'Ethereum Mainnet', + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.MAINNET, + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + [NETWORK_CHAIN_ID.POLYGON]: { + chainId: NETWORK_CHAIN_ID.POLYGON, + name: 'Polygon', + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.POLYGON, + url: 'https://polygon-rpc.com', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(result).toEqual({ + [NETWORK_CHAIN_ID.MAINNET]: true, + [NETWORK_CHAIN_ID.POLYGON]: true, + }); + }); + + it('should handle empty networks object', () => { + const result = enableAllNetworksFilter({}); + expect(result).toEqual({}); + }); + + it('should work with NETWORK_CHAIN_ID constants', () => { + const mockNetworks: FlareTestNetworkConfigurations = { + [NETWORK_CHAIN_ID.FLARE_MAINNET]: { + chainId: NETWORK_CHAIN_ID.FLARE_MAINNET, + name: 'Flare Mainnet', + blockExplorerUrls: ['https://flare.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'FLR', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.FLARE_MAINNET, + url: 'https://flare-rpc.com', + }, + ], + }, + [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: { + chainId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET, + name: 'Songbird Testnet', + blockExplorerUrls: ['https://songbird.flare.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'SGB', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.SONGBIRD_TESTNET, + url: 'https://songbird-rpc.flare.network', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(result).toEqual({ + [NETWORK_CHAIN_ID.FLARE_MAINNET]: true, + [NETWORK_CHAIN_ID.SONGBIRD_TESTNET]: true, + }); + }); + + it('should handle networks with different property values', () => { + const mockNetworks: MultiNetworkConfigurations = { + [NETWORK_CHAIN_ID.MAINNET]: { + chainId: NETWORK_CHAIN_ID.MAINNET, + name: 'Network 1', + blockExplorerUrls: ['https://etherscan.io'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.MAINNET, + url: 'https://mainnet.infura.io/v3/your-api-key', + }, + ], + }, + [NETWORK_CHAIN_ID.POLYGON]: { + chainId: NETWORK_CHAIN_ID.POLYGON, + name: 'Network 2', + blockExplorerUrls: ['https://polygonscan.com'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'MATIC', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.POLYGON, + url: 'https://polygon-rpc.com', + }, + ], + }, + [NETWORK_CHAIN_ID.BASE]: { + chainId: NETWORK_CHAIN_ID.BASE, + name: 'Network 3', + blockExplorerUrls: ['https://base.network'], + defaultRpcEndpointIndex: 0, + nativeCurrency: 'BASE', + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + networkClientId: NETWORK_CHAIN_ID.BASE, + url: 'https://base-rpc.com', + }, + ], + }, + }; + + const result = enableAllNetworksFilter(mockNetworks); + + expect(Object.values(result).every((value) => value === true)).toBe(true); + expect(Object.keys(result)).toEqual([ + NETWORK_CHAIN_ID.MAINNET, + NETWORK_CHAIN_ID.POLYGON, + NETWORK_CHAIN_ID.BASE, + ]); + }); +}); diff --git a/app/components/UI/Tokens/util/filterAssets.test.ts b/app/components/UI/Tokens/util/filterAssets.test.ts new file mode 100644 index 00000000000..4c23fe54815 --- /dev/null +++ b/app/components/UI/Tokens/util/filterAssets.test.ts @@ -0,0 +1,183 @@ +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 new file mode 100644 index 00000000000..7d201831b57 --- /dev/null +++ b/app/components/UI/Tokens/util/filterAssets.ts @@ -0,0 +1,91 @@ +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/Transactions/index.js b/app/components/UI/Transactions/index.js index 3e251d4cfd7..3cc18c5725a 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -104,6 +104,12 @@ const createStyles = (colors, typography) => color: colors.text.muted, ...fontStyles.normal, }, + textTransactions: { + fontSize: 20, + color: colors.text.muted, + textAlign: 'center', + ...fontStyles.normal, + }, viewMoreWrapper: { padding: 16, }, @@ -572,7 +578,7 @@ class Transactions extends PureComponent { const onConfirmation = (isComplete) => { if (isComplete) { transaction.speedUpParams && - transaction.speedUpParams?.type === 'SpeedUp' + transaction.speedUpParams?.type === 'SpeedUp' ? this.onSpeedUpCompleted() : this.onCancelCompleted(); } @@ -758,8 +764,8 @@ class Transactions extends PureComponent { const transactions = submittedTransactions && submittedTransactions.length ? submittedTransactions - .sort((a, b) => b.time - a.time) - .concat(confirmedTransactions) + .sort((a, b) => b.time - a.time) + .concat(confirmedTransactions) : this.props.transactions; const renderRetryGas = (rate) => { diff --git a/app/components/Views/Asset/index.js b/app/components/Views/Asset/index.js index 2c22b4307bb..bc6e24b35cd 100644 --- a/app/components/Views/Asset/index.js +++ b/app/components/Views/Asset/index.js @@ -19,6 +19,7 @@ import { import AppConstants from '../../../core/AppConstants'; import { swapsLivenessSelector, + swapsTokensMultiChainObjectSelector, swapsTokensObjectSelector, } from '../../../reducers/swaps'; import { @@ -34,6 +35,7 @@ import { toLowerCaseEquals } from '../../../util/general'; import { findBlockExplorerForRpc, isMainnetByChainId, + isPortfolioViewEnabled, } from '../../../util/networks'; import { mockTheme, ThemeContext } from '../../../util/theme'; import { addAccountTimeFlagFilter } from '../../../util/transactions'; @@ -192,8 +194,14 @@ class Asset extends PureComponent { ); updateNavBar = (contentOffset = 0) => { - const { navigation, route, chainId, rpcUrl, networkConfigurations } = - this.props; + const { + route: { params }, + navigation, + route, + chainId, + rpcUrl, + networkConfigurations, + } = this.props; const colors = this.context.colors || mockTheme.colors; const isNativeToken = route.params.isETH; const isMainnet = isMainnetByChainId(chainId); @@ -204,7 +212,9 @@ class Asset extends PureComponent { const shouldShowMoreOptionsInNavBar = isMainnet || !isNativeToken || (isNativeToken && blockExplorer); - + const asset = navigation && params; + const currentNetworkName = + this.props.networkConfigurations[asset.chainId]?.name; navigation.setOptions( getNetworkNavbarOptions( route.params?.symbol ?? '', @@ -224,6 +234,7 @@ class Asset extends PureComponent { : undefined, true, contentOffset, + currentNetworkName, ), ); }; @@ -470,6 +481,7 @@ class Asset extends PureComponent { const asset = navigation && params; const isSwapsFeatureLive = this.props.swapsIsLive; const isNetworkAllowed = isSwapsAllowed(chainId); + const isAssetAllowed = asset.isETH || asset.address?.toLowerCase() in this.props.swapsTokens; @@ -511,6 +523,7 @@ class Asset extends PureComponent { loading={!transactionsUpdated} headerHeight={280} onScrollThroughContent={this.onScrollThroughContent} + tokenChainId={asset.chainId} /> )} @@ -522,7 +535,9 @@ Asset.contextType = ThemeContext; const mapStateToProps = (state) => ({ swapsIsLive: swapsLivenessSelector(state), - swapsTokens: swapsTokensObjectSelector(state), + swapsTokens: isPortfolioViewEnabled() + ? swapsTokensMultiChainObjectSelector(state) + : swapsTokensObjectSelector(state), swapsTransactions: selectSwapsTransactions(state), conversionRate: selectConversionRate(state), currentCurrency: selectCurrentCurrency(state), diff --git a/app/components/Views/Asset/index.test.js b/app/components/Views/Asset/index.test.js index 968ed7f4b69..a7461510901 100644 --- a/app/components/Views/Asset/index.test.js +++ b/app/components/Views/Asset/index.test.js @@ -9,6 +9,13 @@ const mockInitialState = { backgroundState: { ...backgroundState, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + TokensController: { + allTokens: { + '0x1': { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': [], + }, + }, + }, }, }, }; diff --git a/app/components/Views/AssetDetails/AssetsDetails.test.tsx b/app/components/Views/AssetDetails/AssetsDetails.test.tsx index 7dfb2cea993..ce066448c97 100644 --- a/app/components/Views/AssetDetails/AssetsDetails.test.tsx +++ b/app/components/Views/AssetDetails/AssetsDetails.test.tsx @@ -62,8 +62,8 @@ const initialState = { TokenBalancesController: { tokenBalances: { [MOCK_ADDRESS_1]: { - '0x1': { - '0xAddress': '0xde0b6B3A7640000', + [CHAIN_IDS.MAINNET]: { + '0xAddress': '0xde0b6b3a7640000', }, }, }, @@ -77,6 +77,28 @@ const initialState = { aggregators: ['Metamask', 'CMC'], }, ], + tokensByChainId: { + [CHAIN_IDS.MAINNET]: [ + { + address: '0xAddress', + symbol: 'TKN', + decimals: 18, + aggregators: ['Metamask', 'CMC'], + }, + ], + }, + allTokens: { + [CHAIN_IDS.MAINNET]: { + [MOCK_ADDRESS_1]: [ + { + address: '0xAddress', + symbol: 'TKN', + decimals: 18, + aggregators: ['Metamask', 'CMC'], + }, + ], + }, + }, }, AccountsController: { internalAccounts: { @@ -117,6 +139,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -146,6 +169,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -195,6 +219,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> @@ -224,6 +249,7 @@ describe('AssetDetails', () => { route={{ params: { address: '0xAddress', + chainId: CHAIN_IDS.MAINNET, }, }} /> diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx index c4e7d682c09..905113db79e 100644 --- a/app/components/Views/AssetDetails/index.tsx +++ b/app/components/Views/AssetDetails/index.tsx @@ -17,7 +17,10 @@ import { useDispatch, useSelector } from 'react-redux'; import EthereumAddress from '../../UI/EthereumAddress'; import Icon from 'react-native-vector-icons/Feather'; import TokenImage from '../../UI/TokenImage'; -import Networks, { getDecimalChainId } from '../../../util/networks'; +import Networks, { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import Engine from '../../../core/Engine'; import Logger from '../../../util/Logger'; import NotificationManager from '../../../core/NotificationManager'; @@ -34,18 +37,30 @@ import Routes from '../../../constants/navigation/Routes'; import { selectChainId, selectProviderConfig, + selectNetworkConfigurationByChainId, } from '../../../selectors/networkController'; import { selectConversionRate, selectCurrentCurrency, + selectConversionRateBySymbol, } from '../../../selectors/currencyRateController'; -import { selectTokens } from '../../../selectors/tokensController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; -import { selectContractBalances } from '../../../selectors/tokenBalancesController'; +import { + selectAllTokens, + selectTokens, +} from '../../../selectors/tokensController'; +import { + selectContractExchangeRates, + selectTokenMarketDataByChainId, +} from '../../../selectors/tokenRatesController'; +import { + selectContractBalances, + selectTokensBalances, +} from '../../../selectors/tokenBalancesController'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { RootState } from 'app/reducers'; import { Colors } from '../../../util/theme/models'; import { Hex } from '@metamask/utils'; +import { selectSelectedInternalAccountAddress } from '../../../selectors/accountsController'; const createStyles = (colors: Colors) => StyleSheet.create({ @@ -100,31 +115,68 @@ interface Props { route: { params: { address: Hex; + chainId: Hex; }; }; } const AssetDetails = (props: Props) => { - const { address } = props.route.params; + const { address, chainId: networkId } = props.route.params; + const { colors } = useTheme(); const { trackEvent, createEventBuilder } = useMetrics(); const styles = createStyles(colors); const navigation = useNavigation(); const dispatch = useDispatch(); const providerConfig = useSelector(selectProviderConfig); + const allTokens = useSelector(selectAllTokens); + const selectedAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); + const selectedChainId = useSelector(selectChainId); + const chainId = isPortfolioViewEnabled() ? networkId : selectedChainId; const tokens = useSelector(selectTokens); - const conversionRate = useSelector(selectConversionRate); + + const tokensByChain = useMemo( + () => allTokens?.[chainId as Hex]?.[selectedAccountAddress as Hex] ?? [], + [allTokens, chainId, selectedAccountAddress], + ); + + const conversionRateLegacy = useSelector(selectConversionRate); + const networkConfigurationByChainId = useSelector((state: RootState) => + selectNetworkConfigurationByChainId(state, chainId), + ); + const conversionRateBySymbol = useSelector((state: RootState) => + selectConversionRateBySymbol( + state, + networkConfigurationByChainId?.nativeCurrency, + ), + ); const currentCurrency = useSelector(selectCurrentCurrency); - const chainId = useSelector(selectChainId); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, ); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const tokenBalances = useSelector(selectContractBalances); - const token = useMemo( + const tokenExchangeRatesLegacy = useSelector(selectContractExchangeRates); + const tokenExchangeRatesByChainId = useSelector((state: RootState) => + selectTokenMarketDataByChainId(state, chainId), + ); + const tokenBalancesLegacy = useSelector(selectContractBalances); + const allTokenBalances = useSelector(selectTokensBalances); + + const portfolioToken = useMemo( + () => tokensByChain.find((rawToken) => rawToken.address === address), + [tokensByChain, address], + ); + + const legacyToken = useMemo( () => tokens.find((rawToken) => rawToken.address === address), [tokens, address], ); + + const token: TokenType | undefined = isPortfolioViewEnabled() + ? portfolioToken + : legacyToken; + const { symbol, decimals, aggregators = [] } = token as TokenType; const getNetworkName = () => { @@ -172,8 +224,11 @@ const AssetDetails = (props: Props) => { onConfirm: () => { navigation.navigate('WalletView'); InteractionManager.runAfterInteractions(() => { + const { NetworkController } = Engine.context; + const networkClientId = + NetworkController.findNetworkClientIdByChainId(chainId); try { - TokensController.ignoreTokens([address]); + TokensController.ignoreTokens([address], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, @@ -259,14 +314,39 @@ const AssetDetails = (props: Props) => { const renderTokenBalance = () => { let balanceDisplay = ''; + const tokenExchangeRates = isPortfolioViewEnabled() + ? tokenExchangeRatesByChainId + : tokenExchangeRatesLegacy; + const tokenBalances = isPortfolioViewEnabled() + ? allTokenBalances + : tokenBalancesLegacy; + + const multiChainTokenBalance = + Object.keys(allTokenBalances).length > 0 + ? allTokenBalances[selectedAccountAddress as Hex]?.[chainId as Hex]?.[ + address as Hex + ] + : undefined; + + const tokenBalance = isPortfolioViewEnabled() + ? multiChainTokenBalance + : tokenBalancesLegacy[address]; + + const conversionRate = isPortfolioViewEnabled() + ? conversionRateBySymbol + : conversionRateLegacy; + const exchangeRate = tokenExchangeRates && address in tokenExchangeRates ? tokenExchangeRates[address]?.price : undefined; - const balance = - address in tokenBalances - ? renderFromTokenMinimalUnit(tokenBalances[address], decimals) - : undefined; + + const balance = tokenBalance + ? address in tokenBalances || isPortfolioViewEnabled() || !tokenBalance + ? renderFromTokenMinimalUnit(tokenBalance.toString(), decimals) + : undefined + : undefined; + const balanceFiat = balance ? balanceToFiat(balance, conversionRate, exchangeRate, currentCurrency) : undefined; diff --git a/app/components/Views/AssetOptions/AssetOptions.test.tsx b/app/components/Views/AssetOptions/AssetOptions.test.tsx index 5a430f103ba..d7e298bf86a 100644 --- a/app/components/Views/AssetOptions/AssetOptions.test.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.test.tsx @@ -3,6 +3,85 @@ import { render, fireEvent } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import AssetOptions from './AssetOptions'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; + +import { + createProviderConfig, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; + +jest.mock('../../../core/Engine', () => ({ + context: { + TokensController: { + ignoreTokens: jest.fn(() => Promise.resolve()), + }, + NetworkController: { + findNetworkClientIdByChainId: jest.fn(() => 'test-network'), + getNetworkClientById: jest.fn(() => ({ + configuration: { + chainId: '0x1', + rpcUrl: 'https://mainnet.example.com', + ticker: 'ETH', + type: 'mainnet', + }, + })), + state: { + providerConfig: { + chainId: '0x1', + type: 'mainnet', + }, + networkConfigurations: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + }, + }, + }, + TokenDetectionController: { + detectTokens: jest.fn(() => Promise.resolve()), + }, + AccountTrackerController: { + refresh: jest.fn(() => Promise.resolve()), + }, + CurrencyRateController: { + updateExchangeRate: jest.fn(() => Promise.resolve()), + }, + TokenRatesController: { + updateExchangeRatesByChainId: jest.fn(() => Promise.resolve()), + }, + }, + getTotalFiatAccountBalance: jest.fn(), +})); + +jest.mock('../../../selectors/networkController', () => ({ + selectChainId: jest.fn(() => '1'), + selectProviderConfig: jest.fn(() => ({})), + selectNetworkConfigurations: jest.fn(() => ({ + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [{ url: 'https://polygon.example.com' }], + defaultRpcEndpointIndex: 0, + }, + })), + createProviderConfig: jest.fn((networkConfig, rpcEndpoint) => ({ + chainId: networkConfig.chainId, + rpcUrl: rpcEndpoint.url, + chainName: 'Example Chain', + nativeCurrency: { + name: 'Example Token', + symbol: 'EXAMPLE', + decimals: 18, + }, + })), +})); // Mock dependencies jest.mock('@react-navigation/native', () => ({ @@ -68,6 +147,16 @@ jest.mock('../../../selectors/networkController', () => ({ selectChainId: jest.fn(() => '1'), selectProviderConfig: jest.fn(() => ({})), selectNetworkConfigurations: jest.fn(() => ({})), + createProviderConfig: jest.fn(() => ({ + chainId: '1', + rpcUrl: 'https://example.com', + chainName: 'Example Chain', + nativeCurrency: { + name: 'Example Token', + symbol: 'EXAMPLE', + decimals: 18, + }, + })), })); jest.mock('../../../selectors/tokenListController', () => ({ @@ -89,10 +178,21 @@ describe('AssetOptions Component', () => { return { '0x123': { symbol: 'ABC' } }; return {}; }); + jest.clearAllMocks(); + jest.useRealTimers(); + jest.useFakeTimers(); }); afterEach(() => { + jest.runAllTimers(); + jest.clearAllTimers(); + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + afterAll(() => { jest.clearAllMocks(); + jest.clearAllTimers(); }); it('matches the snapshot', () => { @@ -101,6 +201,24 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', + isNativeCurrency: false, + }, + }} + />, + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('should match the snapshot when portfolio view is enabled ', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + const { toJSON } = render( + { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -134,6 +253,7 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -141,6 +261,7 @@ describe('AssetOptions Component', () => { ); fireEvent.press(getByText('View on block explorer')); + jest.runAllTimers(); expect(mockNavigation.navigate).toHaveBeenCalledWith('Webview', { screen: 'SimpleWebview', params: { @@ -156,6 +277,7 @@ describe('AssetOptions Component', () => { route={{ params: { address: '0x123', + chainId: '0x1', isNativeCurrency: false, }, }} @@ -163,6 +285,7 @@ describe('AssetOptions Component', () => { ); fireEvent.press(getByText('Remove token')); + jest.runAllTimers(); expect(mockNavigation.navigate).toHaveBeenCalledWith('RootModalFlow', { screen: 'AssetHideConfirmation', params: expect.anything(), @@ -170,18 +293,64 @@ describe('AssetOptions Component', () => { }); it('handles "Token Details" press', () => { - const { getByText } = render( - , - ); + const mockParams = { + params: { + address: '0x123', + chainId: '0x1', + isNativeCurrency: false, + }, + }; + const { getByText } = render(); fireEvent.press(getByText('Token details')); - expect(mockNavigation.navigate).toHaveBeenCalledWith('AssetDetails'); + jest.runAllTimers(); + expect(mockNavigation.navigate).toHaveBeenCalledWith( + 'AssetDetails', + expect.anything(), + ); + }); + + describe('Portfolio and Network Configuration', () => { + const mockNetworkConfigurations = { + '0x1': { + chainId: '0x1', + rpcEndpoints: [{ url: 'https://mainnet.example.com' }], + defaultRpcEndpointIndex: 0, + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [{ url: 'https://polygon.example.com' }], + defaultRpcEndpointIndex: 0, + }, + }; + + beforeEach(() => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectNetworkConfigurations) + return mockNetworkConfigurations; + return {}; + }); + }); + + it('should use correct provider config when portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + render( + , + ); + + expect(createProviderConfig).toHaveBeenCalledWith( + mockNetworkConfigurations['0x1'], + mockNetworkConfigurations['0x1'].rpcEndpoints[0], + ); + }); }); }); diff --git a/app/components/Views/AssetOptions/AssetOptions.tsx b/app/components/Views/AssetOptions/AssetOptions.tsx index d19a4d4c749..83606f2f692 100644 --- a/app/components/Views/AssetOptions/AssetOptions.tsx +++ b/app/components/Views/AssetOptions/AssetOptions.tsx @@ -1,5 +1,5 @@ import { useNavigation } from '@react-navigation/native'; -import React, { useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Text, TouchableOpacity, View, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSelector } from 'react-redux'; @@ -14,6 +14,7 @@ import Icon, { } from '../../../component-library/components/Icons/Icon'; import useBlockExplorer from '../../../components/UI/Swaps/utils/useBlockExplorer'; import { + createProviderConfig, selectChainId, selectNetworkConfigurations, selectProviderConfig, @@ -24,10 +25,14 @@ import { selectTokenList } from '../../../selectors/tokenListController'; import Logger from '../../../util/Logger'; import { MetaMetricsEvents } from '../../../core/Analytics'; import AppConstants from '../../../core/AppConstants'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { isPortfolioUrl } from '../../../util/url'; import { BrowserTab } from '../../../components/UI/Tokens/types'; import { RootState } from '../../../reducers'; +import { Hex } from '../../../util/smart-transactions/smart-publish-hook'; interface Option { label: string; onPress: () => void; @@ -39,12 +44,13 @@ interface Props { params: { address: string; isNativeCurrency: boolean; + chainId: string; }; }; } const AssetOptions = (props: Props) => { - const { address, isNativeCurrency } = props.route.params; + const { address, isNativeCurrency, chainId: networkId } = props.route.params; const { styles } = useStyles(styleSheet, {}); const safeAreaInsets = useSafeAreaInsets(); const navigation = useNavigation(); @@ -58,7 +64,33 @@ const AssetOptions = (props: Props) => { const isDataCollectionForMarketingEnabled = useSelector( (state: RootState) => state.security.dataCollectionForMarketing, ); - const explorer = useBlockExplorer(providerConfig, networkConfigurations); + + // Memoize the provider config for the token explorer + const { providerConfigTokenExplorer } = useMemo(() => { + const tokenNetworkConfig = networkConfigurations[networkId as Hex]; + const tokenRpcEndpoint = + networkConfigurations[networkId as Hex]?.rpcEndpoints?.[ + networkConfigurations[networkId as Hex]?.defaultRpcEndpointIndex + ]; + + const providerConfigToken = createProviderConfig( + tokenNetworkConfig, + tokenRpcEndpoint, + ); + + const providerConfigTokenExplorerToken = isPortfolioViewEnabled() + ? providerConfigToken + : providerConfig; + + return { + providerConfigTokenExplorer: providerConfigTokenExplorerToken, + }; + }, [networkId, networkConfigurations, providerConfig]); + + const explorer = useBlockExplorer( + providerConfigTokenExplorer, + networkConfigurations, + ); const { trackEvent, isEnabled, createEventBuilder } = useMetrics(); const goToBrowserUrl = (url: string, title: string) => { @@ -88,7 +120,10 @@ const AssetOptions = (props: Props) => { const openTokenDetails = () => { modalRef.current?.dismissModal(() => { - navigation.navigate('AssetDetails'); + navigation.navigate('AssetDetails', { + address, + chainId: networkId, + }); }); }; @@ -146,7 +181,18 @@ const AssetOptions = (props: Props) => { navigation.navigate('WalletView'); InteractionManager.runAfterInteractions(async () => { try { - await TokensController.ignoreTokens([address]); + const { NetworkController } = Engine.context; + + const chainIdToUse = isPortfolioViewEnabled() + ? networkId + : chainId; + + const networkClientId = + NetworkController.findNetworkClientIdByChainId( + chainIdToUse as Hex, + ); + + await TokensController.ignoreTokens([address], networkClientId); NotificationManager.showSimpleNotification({ status: `simple_notification`, duration: 5000, diff --git a/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap b/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap index fdb892243bb..ff4e041021f 100644 --- a/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap +++ b/app/components/Views/AssetOptions/__snapshots__/AssetOptions.test.tsx.snap @@ -135,3 +135,139 @@ exports[`AssetOptions Component matches the snapshot 1`] = ` `; + +exports[`AssetOptions Component should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + View on Portfolio + + + + + + + + View on block explorer + + + + + + + + Token details + + + + + + + + Remove token + + + + + + +`; diff --git a/app/components/Views/DetectedTokens/components/Token.tsx b/app/components/Views/DetectedTokens/components/Token.tsx index b79049b80b7..1c113c6ef3f 100644 --- a/app/components/Views/DetectedTokens/components/Token.tsx +++ b/app/components/Views/DetectedTokens/components/Token.tsx @@ -20,7 +20,7 @@ import { } from '../../../../util/number'; import { useTheme } from '../../../../util/theme'; import { - selectConversionRateFoAllChains, + selectCurrencyRates, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; import { selectTokenMarketData } from '../../../../selectors/tokenRatesController'; @@ -115,7 +115,7 @@ const Token = ({ token, selected, toggleSelected }: Props) => { tokenBalancesAllChains[accountAddress as Hex]; const tokenBalances = balanceAllChainsForAccount[(token.chainId as Hex) ?? currentChainId]; - const conversionRateByChainId = useSelector(selectConversionRateFoAllChains); + const conversionRateByChainId = useSelector(selectCurrencyRates); const chainIdToUse = token.chainId ?? currentChainId; const conversionRate = diff --git a/app/components/Views/DetectedTokens/index.tsx b/app/components/Views/DetectedTokens/index.tsx index 61d3f557beb..4078647508d 100644 --- a/app/components/Views/DetectedTokens/index.tsx +++ b/app/components/Views/DetectedTokens/index.tsx @@ -12,6 +12,7 @@ import { Token as TokenType } from '@metamask/assets-controllers'; import { useNavigation } from '@react-navigation/native'; import { FlatList } from 'react-native-gesture-handler'; import { Hex } from '@metamask/utils'; + // External Dependencies import { MetaMetricsEvents } from '../../../core/Analytics'; import { fontStyles } from '../../../styles/common'; @@ -22,7 +23,10 @@ import NotificationManager from '../../../core/NotificationManager'; import { strings } from '../../../../locales/i18n'; import Logger from '../../../util/Logger'; import { useTheme } from '../../../util/theme'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { createNavigationDetails } from '../../../util/navigation/navUtils'; import Routes from '../../../constants/navigation/Routes'; import { @@ -84,8 +88,6 @@ interface IgnoredTokensByAddress { [address: string]: true; } -const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true'; - const DetectedTokens = () => { const navigation = useNavigation(); const { trackEvent, createEventBuilder } = useMetrics(); @@ -100,14 +102,13 @@ const DetectedTokens = () => { const [ignoredTokens, setIgnoredTokens] = useState( {}, ); - const isAllNetworks = useSelector(selectIsAllNetworks); const { colors } = useTheme(); const styles = createStyles(colors); const currentDetectedTokens = - isPortfolioViewEnabled && isAllNetworks + isPortfolioViewEnabled() && isAllNetworks ? allDetectedTokens : detectedTokens; @@ -193,7 +194,7 @@ const DetectedTokens = () => { await Promise.all(ignorePromises); } if (tokensToImport.length > 0) { - if (isPortfolioViewEnabled) { + if (isPortfolioViewEnabled()) { // Group tokens by their `chainId` using a plain object const tokensByChainId: Record = {}; diff --git a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx index 9e4c7ffefde..3866801562d 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.test.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import { fireEvent, waitFor } from '@testing-library/react-native'; + // External dependencies import renderWithProvider from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; @@ -57,6 +58,15 @@ jest.mock('../../../core/Engine', () => ({ }, PreferencesController: { setShowTestNetworks: jest.fn(), + setTokenNetworkFilter: jest.fn(), + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, CurrencyRateController: { updateExchangeRate: jest.fn() }, AccountTrackerController: { refresh: jest.fn() }, @@ -205,6 +215,14 @@ const initialState = { }, PreferencesController: { showTestNetworks: false, + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, NftController: { allNfts: { '0x': { '0x1': [] } }, @@ -349,6 +367,14 @@ describe('Network Selector', () => { ...initialState.engine.backgroundState, PreferencesController: { showTestNetworks: true, + tokenNetworkFilter: { + '0x1': true, + '0xe708': true, + '0xa86a': true, + '0x89': true, + '0xa': true, + '0x64': true, + }, }, NetworkController: { selectedNetworkClientId: 'sepolia', diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 3567d9db256..444daa341b1 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -26,7 +26,10 @@ import BottomSheet, { } from '../../../component-library/components/BottomSheets/BottomSheet'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { useSelector } from 'react-redux'; -import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectNetworkConfigurations, + selectIsAllNetworks, +} from '../../../selectors/networkController'; import { selectShowTestNetworks } from '../../../selectors/preferencesController'; import Networks, { getAllNetworks, @@ -123,6 +126,7 @@ const NetworkSelector = () => { const styles = createStyles(colors); const sheetRef = useRef(null); const showTestNetworks = useSelector(selectShowTestNetworks); + const isAllNetworks = useSelector(selectIsAllNetworks); const networkConfigurations = useSelector(selectNetworkConfigurations); @@ -173,6 +177,18 @@ const NetworkSelector = () => { isReadOnly: false, }); + const setTokenNetworkFilter = useCallback( + (chainId: string) => { + const { PreferencesController } = Engine.context; + if (!isAllNetworks) { + PreferencesController.setTokenNetworkFilter({ + [chainId]: true, + }); + } + }, + [isAllNetworks], + ); + const onRpcSelect = useCallback( async (clientId: string, chainId: `0x${string}`) => { const { NetworkController } = Engine.context; @@ -262,6 +278,7 @@ const NetworkSelector = () => { await NetworkController.setActiveNetwork(networkClientId); } + setTokenNetworkFilter(chainId); sheetRef.current?.onCloseBottomSheet(); endTrace({ name: TraceName.SwitchCustomNetwork }); endTrace({ name: TraceName.NetworkSwitch }); @@ -376,6 +393,7 @@ const NetworkSelector = () => { networkConfiguration.defaultRpcEndpointIndex ].networkClientId ?? type; + setTokenNetworkFilter(networkConfiguration.chainId); NetworkController.setActiveNetwork(clientId); closeRpcModal(); AccountTrackerController.refresh(); @@ -430,10 +448,10 @@ const NetworkSelector = () => { const renderMainnet = () => { const { name: mainnetName, chainId } = Networks.mainnet; const rpcEndpoints = networkConfigurations?.[chainId]?.rpcEndpoints; - const rpcUrl = - rpcEndpoints?.[networkConfigurations?.[chainId]?.defaultRpcEndpointIndex] - .url; + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; const name = networkConfigurations?.[chainId]?.name ?? mainnetName; if (isNetworkUiRedesignEnabled() && isNoSearchResults(MAINNET)) return null; @@ -497,8 +515,9 @@ const NetworkSelector = () => { const name = networkConfigurations?.[chainId]?.name ?? lineaMainnetName; const rpcEndpoints = networkConfigurations?.[chainId]?.rpcEndpoints; const rpcUrl = - rpcEndpoints?.[networkConfigurations?.[chainId]?.defaultRpcEndpointIndex] - .url; + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId]?.defaultRpcEndpointIndex + ].url; if (isNetworkUiRedesignEnabled() && isNoSearchResults('linea-mainnet')) return null; diff --git a/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx b/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx index d3f6db25b6c..0545e0cb756 100644 --- a/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx +++ b/app/components/Views/QRTabSwitcher/QRTabSwitcher.test.tsx @@ -35,8 +35,15 @@ jest.mock('@react-navigation/compat', () => { jest.mock('../QRScanner', () => jest.fn(() => null)); jest.mock('../../UI/ReceiveRequest', () => jest.fn(() => null)); +jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); + describe('QRTabSwitcher', () => { - beforeAll(() => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runAllTimers(); jest.useFakeTimers(); }); @@ -46,6 +53,7 @@ describe('QRTabSwitcher', () => { it('renders QRScanner by default', () => { const { getByText } = render(); + jest.runAllTimers(); expect(getByText(strings('qr_tab_switcher.scanner_tab'))).toBeTruthy(); }); @@ -57,6 +65,7 @@ describe('QRTabSwitcher', () => { }, }); const { queryByText } = render(); + jest.runAllTimers(); expect(queryByText(strings('qr_tab_switcher.scanner_tab'))).toBeNull(); expect(queryByText(strings('qr_tab_switcher.receive_tab'))).toBeNull(); }); diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx index 15f495955b6..df2a6c4fef2 100644 --- a/app/components/Views/Wallet/index.tsx +++ b/app/components/Views/Wallet/index.tsx @@ -501,7 +501,7 @@ const Wallet = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any let stakedBalance: any = 0; - const assets = isPortfolioViewEnabled + const assets = isPortfolioViewEnabled() ? [...(tokensByChainIdAndAddress || [])] : [...(tokens || [])]; diff --git a/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx b/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx index 91b67a3beba..2dc54176d7d 100644 --- a/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx +++ b/app/components/Views/confirmations/ApproveView/Approve/index.test.tsx @@ -97,6 +97,34 @@ describe('Approve', () => { alert: { isVisible: false, }, + engine: { + backgroundState: { + ...initialRootState.engine.backgroundState, + AccountsController: { + ...initialRootState.engine.backgroundState.AccountsController, + internalAccounts: { + ...initialRootState.engine.backgroundState.AccountsController + .internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + ...initialRootState.engine.backgroundState.TokensController, + allTokens: { + ...initialRootState.engine.backgroundState.TokensController + .allTokens, + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, + }, + }, }); }); @@ -131,6 +159,29 @@ describe('Approve', () => { }, ], }, + AccountsController: { + ...initialRootState.engine.backgroundState.AccountsController, + internalAccounts: { + ...initialRootState.engine.backgroundState.AccountsController + .internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + ...initialRootState.engine.backgroundState.TokensController, + allTokens: { + ...initialRootState.engine.backgroundState.TokensController + .allTokens, + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, }, }, }); diff --git a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx index 5ffa1f47f4c..4f57005c85d 100644 --- a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx +++ b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx @@ -9,7 +9,6 @@ import TransactionTypes from '../../../../../core/TransactionTypes'; import { AmountViewSelectorsIDs } from '../../../../../../e2e/selectors/SendFlow/AmountView.selectors'; import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils'; const mockTransactionTypes = TransactionTypes; @@ -73,10 +72,6 @@ const mockNavigate = jest.fn(); const CURRENT_ACCOUNT = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; const RECEIVER_ACCOUNT = '0x2a'; -const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([ - CURRENT_ACCOUNT, -]); - const initialState = { engine: { backgroundState: { @@ -111,11 +106,33 @@ const initialState = { AccountTrackerController: { accounts: { [CURRENT_ACCOUNT]: { balance: '0' } }, }, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, NftController: { allNfts: { [CURRENT_ACCOUNT]: { '0x1': [] } }, allNftContracts: { [CURRENT_ACCOUNT]: { '0x1': [] } }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, }, }, settings: { @@ -177,6 +194,23 @@ describe('Amount', () => { }, }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, }, }, transaction: { @@ -223,6 +257,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, transaction: { @@ -287,6 +338,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, transaction: { @@ -347,6 +415,29 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, }, }, transaction: { @@ -402,6 +493,29 @@ describe('Amount', () => { }, }, }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, }, }, transaction: { @@ -450,6 +564,23 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [], + }, + }, + }, }, }, settings: { @@ -499,6 +630,29 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + selectedAccount: CURRENT_ACCOUNT, + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: { currentCurrency: 'usd', currencyRates: { @@ -552,6 +706,29 @@ describe('Amount', () => { TokenRatesController: { marketData: {}, }, + AccountsController: { + internalAccounts: { + accounts: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + selectedAccount: CURRENT_ACCOUNT, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: {}, }, }, @@ -597,6 +774,33 @@ describe('Amount', () => { }, }, }, + AccountsController: { + internalAccounts: { + ...initialState.engine.backgroundState.AccountsController + .internalAccounts, + accounts: { + ...initialState.engine.backgroundState.AccountsController + .internalAccounts.accounts, + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + }, + }, + selectedAccount: CURRENT_ACCOUNT, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [CURRENT_ACCOUNT]: [ + { + address: '0x514910771AF9Ca656af840dff83E8264EcF986CA', + symbol: 'LINK', + decimals: 18, + }, + ], + }, + }, + }, CurrencyRateController: {}, }, }, @@ -638,6 +842,24 @@ describe('Amount', () => { ...initialState.engine, backgroundState: { ...initialState.engine.backgroundState, + TokensController: { + tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, TokenRatesController: { marketData: { '0x1': { diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx b/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx index 371d9cd9b48..d8e93bfc710 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/VerifyContractDetails/VerifyContractDetails.test.tsx @@ -5,7 +5,28 @@ import { backgroundState } from '../../../../../../util/test/initial-root-state' const initialState = { engine: { - backgroundState, + backgroundState: { + ...backgroundState, + AccountsController: { + ...backgroundState.AccountsController, + internalAccounts: { + ...backgroundState.AccountsController.internalAccounts, + selectedAccount: '30786334-3935-4563-b064-363339643939', + accounts: { + '30786334-3935-4563-b064-363339643939': { + address: '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, + }, + }, }, settings: { primaryCurrency: 'ETH', diff --git a/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx b/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx index 465c57fd55a..f6a56d3d462 100644 --- a/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx +++ b/app/components/Views/confirmations/components/ApproveTransactionReview/index.test.tsx @@ -63,7 +63,27 @@ const transaction = { const initialState = { engine: { - backgroundState, + backgroundState: { + ...backgroundState, + TokensController: { + tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, + }, }, transaction, settings: { diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx index 2f25159f33b..c954398b018 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.test.tsx @@ -8,14 +8,14 @@ jest.mock('./useTokenRatesPolling', () => jest.fn()); jest.mock('./useTokenDetectionPolling', () => jest.fn()); jest.mock('./useTokenListPolling', () => jest.fn()); jest.mock('./useTokenBalancesPolling', () => jest.fn()); +jest.mock('./useAccountTrackerPolling', () => jest.fn()); describe('AssetPollingProvider', () => { it('should call all polling hooks', () => { - render(
-
+ , ); expect(jest.requireMock('./useCurrencyRatePolling')).toHaveBeenCalled(); @@ -23,5 +23,6 @@ describe('AssetPollingProvider', () => { expect(jest.requireMock('./useTokenDetectionPolling')).toHaveBeenCalled(); expect(jest.requireMock('./useTokenListPolling')).toHaveBeenCalled(); expect(jest.requireMock('./useTokenBalancesPolling')).toHaveBeenCalled(); + expect(jest.requireMock('./useAccountTrackerPolling')).toHaveBeenCalled(); }); }); diff --git a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx index 33fc54e753e..4cc7f880aec 100644 --- a/app/components/hooks/AssetPolling/AssetPollingProvider.tsx +++ b/app/components/hooks/AssetPolling/AssetPollingProvider.tsx @@ -4,6 +4,7 @@ import useTokenRatesPolling from './useTokenRatesPolling'; import useTokenDetectionPolling from './useTokenDetectionPolling'; import useTokenListPolling from './useTokenListPolling'; import useTokenBalancesPolling from './useTokenBalancesPolling'; +import useAccountTrackerPolling from './useAccountTrackerPolling'; // This provider is a step towards making controller polling fully UI based. // Eventually, individual UI components will call the use*Polling hooks to @@ -12,6 +13,7 @@ export const AssetPollingProvider = ({ children }: { children: ReactNode }) => { useCurrencyRatePolling(); useTokenRatesPolling(); useTokenDetectionPolling(); + useAccountTrackerPolling(); useTokenListPolling(); useTokenBalancesPolling(); diff --git a/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts b/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts new file mode 100644 index 00000000000..195d1226c6a --- /dev/null +++ b/app/components/hooks/AssetPolling/useAccountTrackerPolling.ts @@ -0,0 +1,54 @@ +import { useSelector } from 'react-redux'; +import usePolling from '../usePolling'; +import { + selectNetworkConfigurations, + selectSelectedNetworkClientId, +} from '../../../selectors/networkController'; +import Engine from '../../../core/Engine'; +import { isPortfolioViewEnabled } from '../../../util/networks'; +import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; + +// Polls native currency prices across networks. +const useAccountTrackerPolling = ({ + networkClientIds, +}: { networkClientIds?: { networkClientId: string }[] } = {}) => { + // Selectors to determine polling input + const networkConfigurations = useSelector(selectNetworkConfigurations); + const selectedNetworkClientId = useSelector(selectSelectedNetworkClientId); + + const accountsByChainId = useSelector(selectAccountsByChainId); + const networkClientIdsConfig = Object.values(networkConfigurations).map( + (network) => ({ + networkClientId: + network?.rpcEndpoints?.[network?.defaultRpcEndpointIndex] + ?.networkClientId, + }), + ); + + const chainIdsToPoll = isPortfolioViewEnabled() + ? networkClientIds ?? networkClientIdsConfig + : [ + { + networkClientId: selectedNetworkClientId, + }, + ]; + + const { AccountTrackerController } = Engine.context; + + usePolling({ + startPolling: AccountTrackerController.startPolling.bind( + AccountTrackerController, + ), + stopPollingByPollingToken: + AccountTrackerController.stopPollingByPollingToken.bind( + AccountTrackerController, + ), + input: chainIdsToPoll, + }); + + return { + accountsByChainId, + }; +}; + +export default useAccountTrackerPolling; diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts index e2a3862f7e0..2411b4c6f61 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenBalancesPolling from './useTokenBalancesPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenBalancesPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -29,9 +30,11 @@ describe('useTokenBalancesPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, '0x89': {}, }, @@ -40,19 +43,80 @@ describe('useTokenBalancesPolling', () => { }, }; - it('Should poll by selected chain id, and stop polling on dismount', async () => { + it('should poll by selected chain id when portfolio view is disabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); - const { unmount } = renderHookWithProvider(() => useTokenBalancesPolling(), {state}); + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling(), + { + state, + }, + ); - const mockedTokenBalancesController = jest.mocked(Engine.context.TokenBalancesController); + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(1); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + + unmount(); + expect( + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('should poll all network configurations when portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling(), + { + state, + }, + ); + + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); + + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(2); // For both chain IDs + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: '0x89', + }); + + unmount(); expect( - mockedTokenBalancesController.startPolling - ).toHaveBeenCalledWith({chainId: selectedChainId}); + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(2); + }); + + it('should use provided chainIds when specified, even with portfolio view enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const specificChainIds = ['0x5' as const]; + const { unmount } = renderHookWithProvider( + () => useTokenBalancesPolling({ chainIds: specificChainIds }), + { state }, + ); + + const mockedTokenBalancesController = jest.mocked( + Engine.context.TokenBalancesController, + ); + + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledTimes(1); + expect(mockedTokenBalancesController.startPolling).toHaveBeenCalledWith({ + chainId: '0x5', + }); - expect(mockedTokenBalancesController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenBalancesController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect( + mockedTokenBalancesController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts index 2b67d4ce124..d2f0ca1cc1c 100644 --- a/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenBalancesPolling.ts @@ -1,13 +1,15 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; import { selectAllTokenBalances } from '../../../selectors/tokenBalancesController'; const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - // Selectors to determine polling input const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); @@ -15,22 +17,25 @@ const useTokenBalancesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { // Selectors returning state updated by the polling const tokenBalances = useSelector(selectAllTokenBalances); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenBalancesController } = Engine.context; usePolling({ - startPolling: - TokenBalancesController.startPolling.bind(TokenBalancesController), + startPolling: TokenBalancesController.startPolling.bind( + TokenBalancesController, + ), stopPollingByPollingToken: - TokenBalancesController.stopPollingByPollingToken.bind(TokenBalancesController), - input: chainIdsToPoll.map((chainId) => ({chainId: chainId as Hex})), + TokenBalancesController.stopPollingByPollingToken.bind( + TokenBalancesController, + ), + input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })), }); return { - tokenBalances + tokenBalances, }; }; diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts index 1294d23cad8..b454c1be5e8 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenDetectionPolling from './useTokenDetectionPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenDetectionPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -28,8 +29,8 @@ describe('useTokenDetectionPolling', () => { selectedAccount: '1', accounts: { '1': { - address: selectedAddress - } + address: selectedAddress, + }, }, }, }, @@ -41,11 +42,12 @@ describe('useTokenDetectionPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, - '0x89': {}, }, }, }, @@ -53,40 +55,212 @@ describe('useTokenDetectionPolling', () => { }; it('Should poll by current chain ids/address, and stop polling on dismount', async () => { + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { state }, + ); - const { unmount } = renderHookWithProvider(() => useTokenDetectionPolling(), {state}); + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); - const mockedTokenDetectionController = jest.mocked(Engine.context.TokenDetectionController); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [selectedChainId], + address: selectedAddress, + }); - expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes(1); expect( - mockedTokenDetectionController.startPolling - ).toHaveBeenCalledWith({chainIds: [selectedChainId], address: selectedAddress}); - - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); - + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); it('Should not poll when token detection is disabled', async () => { + renderHookWithProvider( + () => useTokenDetectionPolling({ chainIds: ['0x1'] }), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + PreferencesController: { + ...state.engine.backgroundState.PreferencesController, + useTokenDetection: false, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 0, + ); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); + }); + + it('Should poll with specific chainIds when provided', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const specificChainIds = ['0x5' as const]; + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling({ chainIds: specificChainIds }), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x5': { + chainId: '0x5', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + }, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: ['0x5'], + address: selectedAddress, + }); + + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('Should poll with network configurations when no chainIds provided', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); + + const currentChainId = '0x1'; + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + NetworkController: { + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + [currentChainId]: { + chainId: currentChainId, + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + '0x89': { + chainId: '0x89', + rpcEndpoints: [ + { + networkClientId: 'otherNetworkClientId', + }, + ], + }, + }, + }, + }, + }, + }, + }, + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); - renderHookWithProvider(() => useTokenDetectionPolling({chainIds: ['0x1']}), {state:{ - ...state, - engine: { - ...state.engine, - backgroundState: { - ...state.engine.backgroundState, - PreferencesController: { - ...state.engine.backgroundState.PreferencesController, - useTokenDetection: false, + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [currentChainId], + address: selectedAddress, + }); + + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); + }); + + it('Should handle missing account address gracefully', async () => { + const { unmount } = renderHookWithProvider( + () => useTokenDetectionPolling(), + { + state: { + ...state, + engine: { + ...state.engine, + backgroundState: { + ...state.engine.backgroundState, + AccountsController: { + internalAccounts: { + selectedAccount: '1', + accounts: { + '1': { + address: undefined, + }, + }, + }, + }, + }, }, }, }, - }}); + ); + + const mockedTokenDetectionController = jest.mocked( + Engine.context.TokenDetectionController, + ); + + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes( + 1, + ); + expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledWith({ + chainIds: [selectedChainId], + address: undefined, + }); - const mockedTokenDetectionController = jest.mocked(Engine.context.TokenDetectionController); - expect(mockedTokenDetectionController.startPolling).toHaveBeenCalledTimes(0); - expect(mockedTokenDetectionController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); + unmount(); + expect( + mockedTokenDetectionController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts index d07ba5c46f9..dccdf39acbd 100644 --- a/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenDetectionPolling.ts @@ -1,37 +1,46 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; import { selectSelectedInternalAccount } from '../../../selectors/accountsController'; import { selectUseTokenDetection } from '../../../selectors/preferencesController'; const useTokenDetectionPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); const selectedAccount = useSelector(selectSelectedInternalAccount); const useTokenDetection = useSelector(selectUseTokenDetection); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenDetectionController } = Engine.context; usePolling({ - startPolling: - TokenDetectionController.startPolling.bind(TokenDetectionController), + startPolling: TokenDetectionController.startPolling.bind( + TokenDetectionController, + ), stopPollingByPollingToken: - TokenDetectionController.stopPollingByPollingToken.bind(TokenDetectionController), - input: useTokenDetection ? [{ - chainIds: chainIdsToPoll as Hex[], - address: selectedAccount?.address as Hex - }] : [] + TokenDetectionController.stopPollingByPollingToken.bind( + TokenDetectionController, + ), + input: useTokenDetection + ? [ + { + chainIds: chainIdsToPoll as Hex[], + address: selectedAccount?.address as Hex, + }, + ] + : [], }); - return { }; + return {}; }; export default useTokenDetectionPolling; diff --git a/app/components/hooks/AssetPolling/useTokenListPolling.test.ts b/app/components/hooks/AssetPolling/useTokenListPolling.test.ts index cbf1ff805e5..6ffc511088e 100644 --- a/app/components/hooks/AssetPolling/useTokenListPolling.test.ts +++ b/app/components/hooks/AssetPolling/useTokenListPolling.test.ts @@ -1,6 +1,8 @@ import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; import useTokenListPolling from './useTokenListPolling'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; jest.mock('../../../core/Engine', () => ({ context: { @@ -12,7 +14,6 @@ jest.mock('../../../core/Engine', () => ({ })); describe('useTokenListPolling', () => { - beforeEach(() => { jest.resetAllMocks(); }); @@ -26,9 +27,11 @@ describe('useTokenListPolling', () => { networkConfigurationsByChainId: { [selectedChainId]: { chainId: selectedChainId, - rpcEndpoints: [{ - networkClientId: 'selectedNetworkClientId', - }] + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], }, '0x89': {}, }, @@ -38,18 +41,52 @@ describe('useTokenListPolling', () => { }; it('Should poll by selected chain id, and stop polling on dismount', async () => { + const { unmount } = renderHookWithProvider(() => useTokenListPolling(), { + state, + }); - const { unmount } = renderHookWithProvider(() => useTokenListPolling(), {state}); - - const mockedTokenListController = jest.mocked(Engine.context.TokenListController); + const mockedTokenListController = jest.mocked( + Engine.context.TokenListController, + ); + const calledAmount = networks.isPortfolioViewEnabled() ? 2 : 1; + expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes( + calledAmount, + ); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); - expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes(1); expect( - mockedTokenListController.startPolling - ).toHaveBeenCalledWith({chainId: selectedChainId}); + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(0); + unmount(); + expect( + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(calledAmount); + }); + + it('Should poll all networks when portfolio view is enabled', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const { unmount } = renderHookWithProvider(() => useTokenListPolling(), { + state, + }); + + const mockedTokenListController = jest.mocked( + Engine.context.TokenListController, + ); + + expect(mockedTokenListController.startPolling).toHaveBeenCalledTimes(2); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: selectedChainId, + }); + expect(mockedTokenListController.startPolling).toHaveBeenCalledWith({ + chainId: '0x89', + }); - expect(mockedTokenListController.stopPollingByPollingToken).toHaveBeenCalledTimes(0); unmount(); - expect(mockedTokenListController.stopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect( + mockedTokenListController.stopPollingByPollingToken, + ).toHaveBeenCalledTimes(2); }); }); diff --git a/app/components/hooks/AssetPolling/useTokenListPolling.ts b/app/components/hooks/AssetPolling/useTokenListPolling.ts index 13bc408efd8..cccce3f4c15 100644 --- a/app/components/hooks/AssetPolling/useTokenListPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenListPolling.ts @@ -1,13 +1,18 @@ import { useSelector } from 'react-redux'; import usePolling from '../usePolling'; import Engine from '../../../core/Engine'; -import { selectChainId, selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectChainId, + selectNetworkConfigurations, +} from '../../../selectors/networkController'; import { Hex } from '@metamask/utils'; import { isPortfolioViewEnabled } from '../../../util/networks'; -import { selectERC20TokensByChain, selectTokenList } from '../../../selectors/tokenListController'; +import { + selectERC20TokensByChain, + selectTokenList, +} from '../../../selectors/tokenListController'; const useTokenListPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { - // Selectors to determine polling input const networkConfigurations = useSelector(selectNetworkConfigurations); const currentChainId = useSelector(selectChainId); @@ -16,18 +21,17 @@ const useTokenListPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const tokenList = useSelector(selectTokenList); const tokenListByChain = useSelector(selectERC20TokensByChain); - const chainIdsToPoll = isPortfolioViewEnabled - ? (chainIds ?? Object.keys(networkConfigurations)) + const chainIdsToPoll = isPortfolioViewEnabled() + ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; const { TokenListController } = Engine.context; usePolling({ - startPolling: - TokenListController.startPolling.bind(TokenListController), + startPolling: TokenListController.startPolling.bind(TokenListController), stopPollingByPollingToken: TokenListController.stopPollingByPollingToken.bind(TokenListController), - input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })) + input: chainIdsToPoll.map((chainId) => ({ chainId: chainId as Hex })), }); return { diff --git a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts index 404295a94e4..25351bccd5b 100644 --- a/app/components/hooks/AssetPolling/useTokenRatesPolling.ts +++ b/app/components/hooks/AssetPolling/useTokenRatesPolling.ts @@ -21,7 +21,7 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: Hex[] } = {}) => { const contractExchangeRates = useSelector(selectContractExchangeRates); const tokenMarketData = useSelector(selectTokenMarketData); - const chainIdsToPoll = isPortfolioViewEnabled + const chainIdsToPoll = isPortfolioViewEnabled() ? chainIds ?? Object.keys(networkConfigurations) : [currentChainId]; diff --git a/app/components/hooks/useAccounts/useAccounts.test.ts b/app/components/hooks/useAccounts/useAccounts.test.ts index f156a7ff974..ad33a8da426 100644 --- a/app/components/hooks/useAccounts/useAccounts.test.ts +++ b/app/components/hooks/useAccounts/useAccounts.test.ts @@ -6,6 +6,8 @@ import { backgroundState } from '../../../util/test/initial-root-state'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; import { Account } from './useAccounts.types'; import { Hex } from '@metamask/utils'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; import { getAccountBalances } from './utils'; const mockReturnGetAccountBalances = getAccountBalances as jest.Mock; @@ -123,6 +125,7 @@ describe('useAccounts', () => { }); it('returns internal accounts', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); mockReturnGetAccountBalances.mockReturnValueOnce({ balanceWeiHex: '0x0', balanceETH: '0', diff --git a/app/components/hooks/useAccounts/utils.ts b/app/components/hooks/useAccounts/utils.ts index 1aa05ca94f1..248f9d8d704 100644 --- a/app/components/hooks/useAccounts/utils.ts +++ b/app/components/hooks/useAccounts/utils.ts @@ -9,7 +9,7 @@ import { } from '../../../util/number'; import { AccountInformation } from '@metamask/assets-controllers'; import { TotalFiatBalancesCrossChains } from '../useGetTotalFiatBalanceCrossChains'; -import { isPortfolioViewEnabledFunction } from '../../../util/networks'; +import { isPortfolioViewEnabled } from '../../../util/networks'; interface AccountInfo { [address: string]: AccountInformation; @@ -40,10 +40,14 @@ export const getAccountBalances = ({ const balanceETH = renderFromWei(totalBalanceWeiHex); // Gives ETH // IF portfolio view is active, display aggregated fiat balance cross chains let balanceFiat; - if (isPortfolioViewEnabledFunction()) { - const { totalFiatBalance } = - totalFiatBalancesCrossChain[internalAccount.address]; - balanceFiat = `${renderFiat(totalFiatBalance, currentCurrency)}`; + if (isPortfolioViewEnabled()) { + const totalFiatBalance = + totalFiatBalancesCrossChain[internalAccount?.address as string] + ?.totalFiatBalance; + balanceFiat = + totalFiatBalance !== undefined + ? `${renderFiat(totalFiatBalance, currentCurrency)}` + : ''; } else { balanceFiat = weiToFiat(hexToBN(totalBalanceWeiHex), conversionRate, currentCurrency) || diff --git a/app/components/hooks/useGetFormattedTokensPerChain.test.ts b/app/components/hooks/useGetFormattedTokensPerChain.test.ts index 75f7123dd99..84424b8820b 100644 --- a/app/components/hooks/useGetFormattedTokensPerChain.test.ts +++ b/app/components/hooks/useGetFormattedTokensPerChain.test.ts @@ -20,13 +20,11 @@ const mockInitialState: DeepPartial = { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', decimals: 6, - name: 'USDC', }, { address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', symbol: 'DAI', decimals: 18, - name: 'Dai Stablecoin', }, ], }, @@ -39,7 +37,6 @@ const mockInitialState: DeepPartial = { image: 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x0d1e753a25ebda689453309112904807625befbe.png', aggregators: ['CoinGecko', 'Lifi', 'Rubic'], - name: 'PancakeSwap', }, ], }, diff --git a/app/reducers/swaps/index.js b/app/reducers/swaps/index.js index 84674aee8ca..31aa743daa6 100644 --- a/app/reducers/swaps/index.js +++ b/app/reducers/swaps/index.js @@ -5,11 +5,15 @@ import { toLowerCaseEquals } from '../../util/general'; import Engine from '../../core/Engine'; import { lte } from '../../util/lodash'; import { selectChainId } from '../../selectors/networkController'; -import { selectTokens } from '../../selectors/tokensController'; +import { + selectAllTokens, + selectTokens, +} from '../../selectors/tokensController'; import { selectContractBalances } from '../../selectors/tokenBalancesController'; import { getChainFeatureFlags, getSwapsLiveness } from './utils'; import { allowedTestnetChainIds } from '../../components/UI/Swaps/utils'; import { NETWORKS_CHAIN_ID } from '../../constants/network'; +import { selectSelectedInternalAccountAddress } from '../../selectors/accountsController'; // If we are in dev and on a testnet, just use mainnet feature flags, // since we don't have feature flags for testnets in the API @@ -190,6 +194,39 @@ const swapsControllerAndUserTokens = createSelector( }, ); +const swapsControllerAndUserTokensMultichain = createSelector( + swapsControllerTokens, + selectAllTokens, + selectSelectedInternalAccountAddress, + (swapsTokens, allTokens, currentUserAddress) => { + const allTokensArr = Object.values(allTokens); + const allUserTokensCrossChains = allTokensArr.reduce( + (acc, tokensElement) => { + const found = tokensElement[currentUserAddress] || []; + return [...acc, ...found.flat()]; + }, + [], + ); + const values = [...(swapsTokens || []), ...(allUserTokensCrossChains || [])] + .filter(Boolean) + .reduce((map, { hasBalanceError, image, ...token }) => { + const key = token.address.toLowerCase(); + + if (!map.has(key)) { + map.set(key, { + occurrences: 0, + ...token, + decimals: Number(token.decimals), + address: key, + }); + } + return map; + }, new Map()) + .values(); + return [...values]; + }, +); + export const swapsTokensSelector = createSelector( chainIdSelector, swapsControllerAndUserTokens, @@ -220,6 +257,21 @@ export const swapsTokensObjectSelector = createSelector( : {}, ); +/** + * Returns a memoized object that only has the addesses cross chains of the tokens as keys + * and undefined as value. Useful to check if a token is supported by swaps. + */ +export const swapsTokensMultiChainObjectSelector = createSelector( + swapsControllerAndUserTokensMultichain, + (tokens) => + tokens?.length > 0 + ? tokens.reduce( + (acc, token) => ({ ...acc, [token.address]: undefined }), + {}, + ) + : {}, +); + /** * Returns an array of tokens to display by default on the selector modal * based on the current account's balances. diff --git a/app/selectors/accountTrackerController.ts b/app/selectors/accountTrackerController.ts index 1673c613172..0798142afa0 100644 --- a/app/selectors/accountTrackerController.ts +++ b/app/selectors/accountTrackerController.ts @@ -1,3 +1,4 @@ + import { createSelector } from 'reselect'; import { AccountTrackerControllerState, diff --git a/app/selectors/accountsController.ts b/app/selectors/accountsController.ts index 1cf9c757be5..3dbf2580f7b 100644 --- a/app/selectors/accountsController.ts +++ b/app/selectors/accountsController.ts @@ -55,6 +55,7 @@ export const selectSelectedInternalAccount = createDeepEqualSelector( const accountId = accountsControllerState.internalAccounts.selectedAccount; const account = accountsControllerState.internalAccounts.accounts[accountId]; + if (!account) { const err = new Error( `selectSelectedInternalAccount: Account with ID ${accountId} not found.`, diff --git a/app/selectors/currencyRateController.test.ts b/app/selectors/currencyRateController.test.ts index 1505a1b4f47..e48f6b957a4 100644 --- a/app/selectors/currencyRateController.test.ts +++ b/app/selectors/currencyRateController.test.ts @@ -2,7 +2,6 @@ import { selectConversionRate, selectCurrentCurrency, selectCurrencyRates, - selectConversionRateFoAllChains, } from './currencyRateController'; import { isTestNet } from '../../app/util/networks'; import { CurrencyRateState } from '@metamask/assets-controllers'; @@ -84,31 +83,15 @@ describe('CurrencyRateController Selectors', () => { }); describe('selectCurrencyRates', () => { - it('returns the currency rates from the state', () => { - const result = selectCurrencyRates.resultFunc( - mockCurrencyRateState as unknown as CurrencyRateState, - ); - expect(result).toStrictEqual(mockCurrencyRateState.currencyRates); - }); - - it('returns undefined if currency rates are not set', () => { - const result = selectCurrencyRates.resultFunc( - {} as unknown as CurrencyRateState, - ); - expect(result).toBeUndefined(); - }); - }); - - describe('selectConversionRateFoAllChains', () => { it('returns all conversion rates from the state', () => { - const result = selectConversionRateFoAllChains.resultFunc( + const result = selectCurrencyRates.resultFunc( mockCurrencyRateState as unknown as CurrencyRateState, ); expect(result).toStrictEqual(mockCurrencyRateState.currencyRates); }); it('returns undefined if conversion rates are not set', () => { - const result = selectConversionRateFoAllChains.resultFunc( + const result = selectCurrencyRates.resultFunc( {} as unknown as CurrencyRateState, ); expect(result).toBeUndefined(); diff --git a/app/selectors/currencyRateController.ts b/app/selectors/currencyRateController.ts index 03bc2624ea7..715ebeb4e8b 100644 --- a/app/selectors/currencyRateController.ts +++ b/app/selectors/currencyRateController.ts @@ -27,6 +27,12 @@ export const selectConversionRate = createSelector( }, ); +export const selectCurrencyRates = createSelector( + selectCurrencyRateControllerState, + (currencyRateControllerState: CurrencyRateState) => + currencyRateControllerState?.currencyRates, +); + export const selectCurrentCurrency = createSelector( selectCurrencyRateControllerState, selectTicker, @@ -35,10 +41,14 @@ export const selectCurrentCurrency = createSelector( currencyRateControllerState?.currentCurrency, ); -export const selectCurrencyRates = createSelector( +export const selectConversionRateBySymbol = createSelector( selectCurrencyRateControllerState, - (currencyRateControllerState: CurrencyRateState) => - currencyRateControllerState?.currencyRates, + (_: RootState, symbol: string) => symbol, + (currencyRateControllerState: CurrencyRateState, symbol: string) => + symbol + ? currencyRateControllerState?.currencyRates?.[symbol]?.conversionRate || + 0 + : 0, ); export const selectConversionRateFoAllChains = createSelector( diff --git a/app/selectors/multichain.test.ts b/app/selectors/multichain.test.ts new file mode 100644 index 00000000000..276517a18cd --- /dev/null +++ b/app/selectors/multichain.test.ts @@ -0,0 +1,188 @@ +import { RootState } from '../reducers'; +import { + selectedAccountNativeTokenCachedBalanceByChainId, + selectAccountTokensAcrossChains, + selectIsBitcoinSupportEnabled, + selectIsBitcoinTestnetSupportEnabled, +} from './multichain'; + +describe('Multichain Selectors', () => { + const mockState: RootState = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + }, + '0x89': { + chainId: '0x89', + name: 'Polygon', + nativeCurrency: 'MATIC', + }, + }, + }, + AccountTrackerController: { + accountsByChainId: { + '0x1': { + '0xAddress1': { + balance: '0x1', + stakedBalance: '0x2', + }, + }, + '0x89': { + '0xAddress1': { + balance: '0x3', + stakedBalance: '0x0', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + '0xAddress1': [ + { + address: '0xToken1', + symbol: 'TK1', + decimals: 18, + balance: '1000000000000000000', + }, + ], + }, + }, + }, + TokenBalancesController: { + tokenBalances: { + '0xAddress1': { + '0x1': { + '0xToken1': '0x1', + }, + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + '0xToken1': { price: 100 }, + }, + }, + }, + CurrencyRateController: { + currentCurrency: 'USD', + conversionRates: { + ETH: 2000, + MATIC: 1, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, + }, + }, + multichainSettings: { + bitcoinSupportEnabled: true, + bitcoinTestnetSupportEnabled: false, + }, + } as unknown as RootState; + + describe('selectedAccountNativeTokenCachedBalanceByChainId', () => { + it('should return native token balances for all chains', () => { + const result = + selectedAccountNativeTokenCachedBalanceByChainId(mockState); + expect(result).toEqual({ + '0x1': { + balance: '0x1', + stakedBalance: '0x2', + isStaked: true, + name: '', + }, + '0x89': { + balance: '0x3', + stakedBalance: '0x0', + isStaked: false, + name: '', + }, + }); + }); + + it('should return empty object when no account is selected', () => { + const stateWithoutAccount = { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine.backgroundState, + AccountsController: { + internalAccounts: { + selectedAccount: undefined, + accounts: {}, + }, + }, + }, + }, + } as unknown as RootState; + + const result = + selectedAccountNativeTokenCachedBalanceByChainId(stateWithoutAccount); + expect(result).toEqual({}); + }); + }); + + describe('selectAccountTokensAcrossChains', () => { + it('should return tokens across all chains for selected account', () => { + const result = selectAccountTokensAcrossChains(mockState); + expect(result).toHaveProperty('0x1'); + + const chain1Tokens = result['0x1'] || []; + expect(chain1Tokens.length).toBeGreaterThan(0); + + const ethToken = chain1Tokens.find( + (token) => token.symbol === 'Ethereum' && !token.isStaked, + ); + expect(ethToken).toBeDefined(); + expect(ethToken?.isNative).toBe(true); + expect(ethToken?.isETH).toBe(true); + + const stakedEthToken = chain1Tokens.find( + (token) => token.symbol === 'Ethereum' && token.isStaked, + ); + expect(stakedEthToken).toBeDefined(); + expect(stakedEthToken?.isNative).toBe(true); + expect(stakedEthToken?.isStaked).toBe(true); + + const tk1Token = chain1Tokens.find((token) => token.symbol === 'TK1'); + expect(tk1Token).toBeDefined(); + expect(tk1Token?.isNative).toBe(false); + }); + + it('should handle multiple chains correctly', () => { + const result = selectAccountTokensAcrossChains(mockState); + expect(result).toHaveProperty('0x89'); + const polygonTokens = result['0x89']; + expect(polygonTokens.length).toBeGreaterThan(0); + expect(polygonTokens.some((token) => token.symbol === 'MATIC')).toBe( + true, + ); + }); + }); + + describe('Bitcoin Support Flags', () => { + it('should return bitcoin support enabled state', () => { + expect(selectIsBitcoinSupportEnabled(mockState)).toBe(true); + }); + + it('should return bitcoin testnet support enabled state', () => { + expect(selectIsBitcoinTestnetSupportEnabled(mockState)).toBe(false); + }); + }); +}); diff --git a/app/selectors/multichain.ts b/app/selectors/multichain.ts index e26741e6cd1..08b6436874f 100644 --- a/app/selectors/multichain.ts +++ b/app/selectors/multichain.ts @@ -1,4 +1,217 @@ +import { createSelector } from 'reselect'; +import { Hex } from '@metamask/utils'; +import { Token, getNativeTokenAddress } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; +import { + selectSelectedInternalAccountFormattedAddress, + selectSelectedInternalAccount, +} from './accountsController'; +import { selectAllTokens } from './tokensController'; +import { selectAccountsByChainId } from './accountTrackerController'; +import { selectNetworkConfigurations } from './networkController'; +import { TokenI } from '../components/UI/Tokens/types'; +import { renderFromWei } from '../util/number'; +import { toHex } from '@metamask/controller-utils'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from './currencyRateController'; +import { selectTokenMarketData } from './tokenRatesController'; + +interface NativeTokenBalance { + balance: string; + stakedBalance: string; + isStaked: boolean; + name: string; +} + +type ChainBalances = Record; + +/** + * Get the cached native token balance for the selected account by chainId. + * + * @param {RootState} state - The root state. + * @returns {ChainBalances} The cached native token balance for the selected account by chainId. + */ +export const selectedAccountNativeTokenCachedBalanceByChainId = createSelector( + [selectSelectedInternalAccountFormattedAddress, selectAccountsByChainId], + (selectedAddress, accountsByChainId): ChainBalances => { + if (!selectedAddress || !accountsByChainId) { + return {}; + } + + const result: ChainBalances = {}; + for (const chainId in accountsByChainId) { + const accounts = accountsByChainId[chainId]; + const account = accounts[selectedAddress]; + if (account) { + result[chainId] = { + balance: account.balance, + stakedBalance: account.stakedBalance ?? '0x0', + isStaked: account.stakedBalance !== '0x0', + name: '', + }; + } + } + + return result; + }, +); + +/** + * Selector to get native tokens for the selected account across all chains. + */ +export const selectNativeTokensAcrossChains = createSelector( + [ + selectNetworkConfigurations, + selectedAccountNativeTokenCachedBalanceByChainId, + selectCurrencyRates, + selectCurrentCurrency, + selectTokenMarketData, + ], + ( + networkConfigurations, + nativeTokenBalancesByChainId, + currencyRates, + currentCurrency, + tokenMarketData, + ) => { + const tokensByChain: { [chainId: string]: TokenI[] } = {}; + for (const token of Object.values(networkConfigurations)) { + const nativeChainId = token.chainId as Hex; + const nativeTokenInfoByChainId = + nativeTokenBalancesByChainId[nativeChainId]; + const isETH = ['ETH', 'GOETH', 'SepoliaETH', 'LineaETH'].includes( + token.nativeCurrency || '', + ); + + const name = isETH ? 'Ethereum' : token.nativeCurrency; + const logo = isETH ? '../images/eth-logo-new.png' : ''; + tokensByChain[nativeChainId] = []; + + if ( + nativeTokenInfoByChainId && + nativeTokenInfoByChainId.isStaked && + nativeTokenInfoByChainId.stakedBalance !== '0x00' && + nativeTokenInfoByChainId.stakedBalance !== toHex(0) + ) { + // Staked tokens + tokensByChain[nativeChainId].push({ + ...nativeTokenInfoByChainId, + chainId: nativeChainId, + address: getNativeTokenAddress(nativeChainId), + balance: renderFromWei(nativeTokenInfoByChainId.stakedBalance), + balanceFiat: '', + isNative: true, + aggregators: [], + image: '', + logo, + isETH, + decimals: 18, + name: 'Staked Ethereum', + symbol: name, + isStaked: true, + ticker: token.nativeCurrency, + }); + } + + const nativeBalanceFormatted = renderFromWei( + nativeTokenInfoByChainId?.balance, + ); + + const tokenMarketDataByChainId = tokenMarketData?.[nativeChainId]; + let balanceFiat = ''; + + if ( + tokenMarketDataByChainId && + Object.keys(tokenMarketDataByChainId).length === 0 + ) { + const balanceFiatValue = + parseFloat(nativeBalanceFormatted) * + (currencyRates?.[token.nativeCurrency]?.conversionRate ?? 0); + + balanceFiat = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currentCurrency, + }).format(balanceFiatValue); + } + + // Non-staked tokens + tokensByChain[nativeChainId].push({ + ...nativeTokenInfoByChainId, + name, + address: getNativeTokenAddress(nativeChainId), + balance: nativeBalanceFormatted, + chainId: nativeChainId, + isNative: true, + aggregators: [], + balanceFiat, + image: '', + logo, + isETH, + decimals: 18, + symbol: name, + isStaked: false, + ticker: token.nativeCurrency, + }); + } + + return tokensByChain; + }, +); + +/** + * Get the tokens for the selected account across all chains. + * + * @param {RootState} state - The root state. + * @returns {TokensByChain} The tokens for the selected account across all chains. + */ +export const selectAccountTokensAcrossChains = createSelector( + [ + selectSelectedInternalAccount, + selectAllTokens, + selectNetworkConfigurations, + selectNativeTokensAcrossChains, + ], + (selectedAccount, allTokens, networkConfigurations, nativeTokens) => { + const selectedAddress = selectedAccount?.address; + const tokensByChain: { + [chainId: string]: ( + | TokenI + | (Token & { isStaked?: boolean; isNative?: boolean; isETH?: boolean }) + )[]; + } = {}; + + if (!selectedAddress) { + return tokensByChain; + } + + // Create a list of available chainIds + const chainIds = Object.keys(networkConfigurations); + + for (const chainId of chainIds) { + const currentChainId = chainId as Hex; + const nonNativeTokens = + allTokens[currentChainId]?.[selectedAddress]?.map((token) => ({ + ...token, + token: token.name, + chainId, + isETH: false, + isNative: false, + balanceFiat: '', + isStaked: false, + })) || []; + + // Add both native and non-native tokens + tokensByChain[currentChainId] = [ + ...(nativeTokens[currentChainId] || []), + ...nonNativeTokens, + ]; + } + + return tokensByChain; + }, +); /** * Get the state of the `bitcoinSupportEnabled` flag. diff --git a/app/selectors/networkController.test.ts b/app/selectors/networkController.test.ts new file mode 100644 index 00000000000..4c636397dc5 --- /dev/null +++ b/app/selectors/networkController.test.ts @@ -0,0 +1,155 @@ +import { + selectNetworkControllerState, + selectProviderConfig, + selectTicker, + selectChainId, + selectProviderType, + selectNickname, + selectRpcUrl, + selectNetworkStatus, + selectNetworkConfigurations, + selectNetworkClientId, + selectIsAllNetworks, + selectNetworkConfigurationByChainId, + selectNativeCurrencyByChainId, +} from './networkController'; +import { RootState } from '../reducers'; + +describe('networkSelectors', () => { + const mockState = { + engine: { + backgroundState: { + NetworkController: { + selectedNetworkClientId: 'custom-network', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + nativeCurrency: 'ETH', + name: 'Ethereum Mainnet', + rpcEndpoints: [ + { + networkClientId: 'infura-mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + blockExplorerUrls: ['https://etherscan.io'], + }, + '0x2': { + chainId: '0x2', + nativeCurrency: 'MATIC', + name: 'Polygon', + rpcEndpoints: [ + { + networkClientId: 'custom-network', + type: 'custom', + url: 'https://polygon-rpc.com', + }, + ], + blockExplorerUrls: ['https://polygonscan.com'], + }, + }, + networksMetadata: { + 'custom-network': { status: 'active' }, + }, + }, + }, + }, + } as unknown as RootState; + + it('selectNetworkControllerState should return the network controller state', () => { + expect(selectNetworkControllerState(mockState)).toEqual( + mockState.engine.backgroundState.NetworkController, + ); + }); + + it('selectProviderConfig should return the provider config for the selected network', () => { + expect(selectProviderConfig(mockState)).toEqual({ + chainId: '0x2', + ticker: 'MATIC', + rpcPrefs: { blockExplorerUrl: 'https://polygonscan.com' }, + type: 'rpc', + id: 'custom-network', + nickname: 'Polygon', + rpcUrl: 'https://polygon-rpc.com', + }); + }); + + it('selectTicker should return the ticker of the provider config', () => { + expect(selectTicker(mockState)).toBe('MATIC'); + }); + + it('selectChainId should return the chainId of the provider config', () => { + expect(selectChainId(mockState)).toBe('0x2'); + }); + + it('selectProviderType should return the type of the provider config', () => { + expect(selectProviderType(mockState)).toBe('rpc'); + }); + + it('selectNickname should return the nickname of the provider config', () => { + expect(selectNickname(mockState)).toBe('Polygon'); + }); + + it('selectRpcUrl should return the rpcUrl of the provider config', () => { + expect(selectRpcUrl(mockState)).toBe('https://polygon-rpc.com'); + }); + + it('selectNetworkStatus should return the network status for the selected network', () => { + expect(selectNetworkStatus(mockState)).toBe('active'); + }); + + it('selectNetworkConfigurations should return the network configurations by chainId', () => { + expect(selectNetworkConfigurations(mockState)).toEqual( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + ); + }); + + it('selectNetworkClientId should return the selected network client ID', () => { + expect(selectNetworkClientId(mockState)).toBe('custom-network'); + }); + + it('selectIsAllNetworks should return false if tokenNetworkFilter length does not match networkConfigurations length', () => { + const tokenNetworkFilter = { '0x1': 'true' }; + expect( + selectIsAllNetworks.resultFunc( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId, + tokenNetworkFilter, + ), + ).toBe(false); + }); + + it('selectNetworkConfigurationByChainId should return the network configuration for a given chainId', () => { + expect(selectNetworkConfigurationByChainId(mockState, '0x2')).toEqual( + mockState.engine.backgroundState.NetworkController + .networkConfigurationsByChainId['0x2'], + ); + }); + + it('selectNativeCurrencyByChainId should return the native currency for a given chainId', () => { + expect(selectNativeCurrencyByChainId(mockState, '0x1')).toBe('ETH'); + }); + + it('should return the default provider config if no matching network is found', () => { + const noMatchState = { ...mockState }; + noMatchState.engine.backgroundState.NetworkController.selectedNetworkClientId = + 'unknown-network'; + expect(selectProviderConfig(noMatchState)).toEqual({ + chainId: '0x2', + id: 'custom-network', + nickname: 'Polygon', + rpcPrefs: { + blockExplorerUrl: 'https://polygonscan.com', + }, + rpcUrl: 'https://polygon-rpc.com', + ticker: 'MATIC', + type: 'rpc', + }); + }); + + it('selectNetworkConfigurationByChainId should return null if the chainId does not exist', () => { + expect(selectNetworkConfigurationByChainId(mockState, '0x9999')).toBeNull(); + }); +}); diff --git a/app/selectors/networkController.ts b/app/selectors/networkController.ts index d37258c979e..c73222939d0 100644 --- a/app/selectors/networkController.ts +++ b/app/selectors/networkController.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { InfuraNetworkType } from '@metamask/controller-utils'; import { @@ -50,7 +51,7 @@ const getDefaultProviderConfig = (): ProviderConfig => ({ }); // Helper function to create the provider config based on the network and endpoint -const createProviderConfig = ( +export const createProviderConfig = ( networkConfig: NetworkConfiguration, rpcEndpoint: RpcEndpoint, ): ProviderConfig => { @@ -80,7 +81,7 @@ const createProviderConfig = ( }; }; -const selectNetworkControllerState = (state: RootState) => +export const selectNetworkControllerState = (state: RootState) => state?.engine?.backgroundState?.NetworkController; export const selectSelectedNetworkClientId = createSelector( @@ -149,7 +150,7 @@ export const selectNetworkStatus = createSelector( export const selectNetworkConfigurations = createSelector( selectNetworkControllerState, (networkControllerState: NetworkState) => - networkControllerState.networkConfigurationsByChainId, + networkControllerState?.networkConfigurationsByChainId, ); export const selectNetworkClientId = createSelector( @@ -173,3 +174,14 @@ export const selectIsAllNetworks = createSelector( Object.keys(tokenNetworkFilter).length === Object.keys(networkConfigurations).length, ); + +export const selectNetworkConfigurationByChainId = createSelector( + [selectNetworkConfigurations, (_state: RootState, chainId: Hex) => chainId], + (networkConfigurations, chainId) => networkConfigurations?.[chainId] || null, +); + +export const selectNativeCurrencyByChainId = createSelector( + [selectNetworkConfigurations, (_state: RootState, chainId: Hex) => chainId], + (networkConfigurations, chainId) => + networkConfigurations?.[chainId]?.nativeCurrency, +); diff --git a/app/selectors/tokenBalancesController.test.ts b/app/selectors/tokenBalancesController.test.ts index 67371ba848d..c49b72af6a1 100644 --- a/app/selectors/tokenBalancesController.test.ts +++ b/app/selectors/tokenBalancesController.test.ts @@ -1,3 +1,4 @@ +import { Hex } from '@metamask/utils'; import { RootState } from '../reducers'; import { selectContractBalances, @@ -30,14 +31,30 @@ describe('TokenBalancesController Selectors', () => { engine: { backgroundState: { TokenBalancesController: mockTokenBalancesControllerState, + NetworkController: { + providerConfig: { + chainId: '0x1', + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: 'account1', + accounts: { + account1: { + id: 'account1', + address: '0xAccount1', + }, + }, + }, + }, }, }, } as unknown as RootState; describe('selectContractBalances', () => { it('returns token balances for the selected account and chain ID', () => { - const selectedAccount = '0xAccount1'; - const chainId = '0x1'; + const selectedAccount: Hex = '0xAccount1'; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -52,8 +69,8 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if no balances exist for the selected account', () => { - const selectedAccount = '0xUnknownAccount'; - const chainId = '0x1'; + const selectedAccount: Hex = '0xUnknownAccount'; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -65,8 +82,8 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if no balances exist for the selected chain ID', () => { - const selectedAccount = '0xAccount1'; - const chainId = '0xUnknownChain'; + const selectedAccount: Hex = '0xAccount1'; + const chainId: Hex = '0xUnknownChain'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, @@ -78,12 +95,12 @@ describe('TokenBalancesController Selectors', () => { }); it('returns an empty object if the selected account is undefined', () => { - const selectedAccount = undefined; - const chainId = '0x1'; + const selectedAccount: Hex | string = ''; + const chainId: Hex = '0x1'; const result = selectContractBalances.resultFunc( mockTokenBalancesControllerState, - selectedAccount, + selectedAccount as `0x${string}`, chainId, ); diff --git a/app/selectors/tokenBalancesController.ts b/app/selectors/tokenBalancesController.ts index c663901490e..dc7385e7182 100644 --- a/app/selectors/tokenBalancesController.ts +++ b/app/selectors/tokenBalancesController.ts @@ -1,14 +1,20 @@ /* eslint-disable import/prefer-default-export */ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { RootState } from '../reducers'; import { TokenBalancesControllerState } from '@metamask/assets-controllers'; -import { Hex } from '@metamask/utils'; import { selectSelectedInternalAccountAddress } from './accountsController'; import { selectChainId } from './networkController'; const selectTokenBalancesControllerState = (state: RootState) => state.engine.backgroundState.TokenBalancesController; +export const selectTokensBalances = createSelector( + selectTokenBalancesControllerState, + (tokenBalancesControllerState: TokenBalancesControllerState) => + tokenBalancesControllerState.tokenBalances, +); + export const selectContractBalances = createSelector( selectTokenBalancesControllerState, selectSelectedInternalAccountAddress, @@ -28,9 +34,3 @@ export const selectAllTokenBalances = createSelector( (tokenBalancesControllerState: TokenBalancesControllerState) => tokenBalancesControllerState.tokenBalances, ); - -export const selectTokensBalances = createSelector( - selectTokenBalancesControllerState, - (tokenBalancesControllerState: TokenBalancesControllerState) => - tokenBalancesControllerState.tokenBalances, -); diff --git a/app/selectors/tokenRatesController.ts b/app/selectors/tokenRatesController.ts index 995e5988fd4..33ff578505d 100644 --- a/app/selectors/tokenRatesController.ts +++ b/app/selectors/tokenRatesController.ts @@ -20,3 +20,8 @@ export const selectTokenMarketData = createSelector( (tokenRatesControllerState: TokenRatesControllerState) => tokenRatesControllerState.marketData, ); + +export const selectTokenMarketDataByChainId = createSelector( + [selectTokenMarketData, (_state: RootState, chainId: Hex) => chainId], + (marketData, chainId) => marketData?.[chainId] || {}, +); diff --git a/app/selectors/tokensController.test.ts b/app/selectors/tokensController.test.ts index eaa963409e8..b29fddd260e 100644 --- a/app/selectors/tokensController.test.ts +++ b/app/selectors/tokensController.test.ts @@ -24,8 +24,8 @@ describe('TokensController Selectors', () => { ignoredTokens: ['0xToken2'], detectedTokens: [mockToken], allTokens: { - '0xAddress1': { - '1': [mockToken], + '0x1': { + '0xAddress1': [mockToken], }, }, allDetectedTokens: { @@ -42,6 +42,16 @@ describe('TokensController Selectors', () => { engine: { backgroundState: { TokensController: mockTokensControllerState, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; @@ -58,14 +68,34 @@ describe('TokensController Selectors', () => { backgroundState: { TokensController: { ...mockTokensControllerState, + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, tokens: [], }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; expect(selectTokens(stateWithoutTokens)).toStrictEqual([]); }); + + it('returns tokens from TokensController state if portfolio view is enabled', () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + expect(selectTokens(mockRootState)).toStrictEqual([mockToken]); + }); }); describe('selectTokensByAddress', () => { @@ -82,8 +112,23 @@ describe('TokensController Selectors', () => { backgroundState: { TokensController: { ...mockTokensControllerState, + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, tokens: [], }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, + }, }, }, } as unknown as RootState; @@ -105,6 +150,21 @@ describe('TokensController Selectors', () => { TokensController: { ...mockTokensControllerState, tokens: [], + allTokens: { + '0x1': { + '0xAddress1': [], + }, + }, + }, + AccountsController: { + internalAccounts: { + selectedAccount: '0xAddress1', + accounts: { + '0xAddress1': { + address: '0xAddress1', + }, + }, + }, }, }, }, @@ -255,9 +315,7 @@ describe('TokensController Selectors', () => { }; it('returns only the current chain ID if PORTFOLIO_VIEW is not set', () => { - jest - .spyOn(networks, 'isPortfolioViewEnabledFunction') - .mockReturnValue(false); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); const chainIds = getChainIdsToPoll.resultFunc( mockNetworkConfigurations, '0x1', @@ -266,9 +324,7 @@ describe('TokensController Selectors', () => { }); it('returns only the current chain ID if PORTFOLIO_VIEW is set', () => { - jest - .spyOn(networks, 'isPortfolioViewEnabledFunction') - .mockReturnValue(true); + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); const chainIds = getChainIdsToPoll.resultFunc( mockNetworkConfigurations, '0x1', diff --git a/app/selectors/tokensController.ts b/app/selectors/tokensController.ts index e95e24d82e1..c76f03a3373 100644 --- a/app/selectors/tokensController.ts +++ b/app/selectors/tokensController.ts @@ -1,13 +1,10 @@ +import { Hex } from '@metamask/utils'; import { createSelector } from 'reselect'; import { TokensControllerState, Token } from '@metamask/assets-controllers'; import { RootState } from '../reducers'; import { createDeepEqualSelector } from './util'; import { selectSelectedInternalAccountAddress } from './accountsController'; -import { Hex } from '@metamask/utils'; -import { - isPortfolioViewEnabledFunction, - TESTNET_CHAIN_IDS, -} from '../util/networks'; +import { isPortfolioViewEnabled, TESTNET_CHAIN_IDS } from '../util/networks'; import { selectChainId, selectNetworkConfigurations, @@ -18,8 +15,21 @@ const selectTokensControllerState = (state: RootState) => export const selectTokens = createDeepEqualSelector( selectTokensControllerState, - (tokensControllerState: TokensControllerState) => - tokensControllerState?.tokens, + selectChainId, + selectSelectedInternalAccountAddress, + ( + tokensControllerState: TokensControllerState, + chainId: Hex, + selectedAddress: string | undefined, + ) => { + if (isPortfolioViewEnabled()) { + return ( + tokensControllerState?.allTokens[chainId]?.[selectedAddress as Hex] || + [] + ); + } + return tokensControllerState?.tokens || []; + }, ); export const selectTokensByChainIdAndAddress = createDeepEqualSelector( @@ -36,7 +46,7 @@ export const selectTokensByChainIdAndAddress = createDeepEqualSelector( export const selectTokensByAddress = createSelector( selectTokens, (tokens: Token[]) => - tokens.reduce((tokensMap: { [address: string]: Token }, token: Token) => { + tokens?.reduce((tokensMap: { [address: string]: Token }, token: Token) => { tokensMap[token.address] = token; return tokensMap; }, {}), @@ -69,7 +79,7 @@ export const getChainIdsToPoll = createDeepEqualSelector( selectNetworkConfigurations, selectChainId, (networkConfigurations, currentChainId) => { - if (!isPortfolioViewEnabledFunction()) { + if (!isPortfolioViewEnabled()) { return [currentChainId]; } @@ -84,16 +94,18 @@ export const getChainIdsToPoll = createDeepEqualSelector( export const selectAllTokensFlat = createSelector( selectAllTokens, - (tokensByAccountByChain) => { + (tokensByAccountByChain: { + [account: string]: { [chainId: string]: Token[] }; + }): Token[] => { if (Object.values(tokensByAccountByChain).length === 0) { return []; } const tokensByAccountArray = Object.values(tokensByAccountByChain); - return tokensByAccountArray.reduce((acc, tokensByAccount) => { - const tokensArray = Object.values(tokensByAccount); + return tokensByAccountArray.reduce((acc, tokensByAccount) => { + const tokensArray = Object.values(tokensByAccount).flat(); return acc.concat(...tokensArray); - }, [] as Token[]); + }, []); }, ); @@ -123,9 +135,6 @@ export const selectAllDetectedTokensForSelectedAddress = createSelector( }, ); -// TODO: This isn't working fully, once a network has been selected then it -// can detect all tokens in that network. But by default it only shows -// detected tokens if the user has chosen it in the past export const selectAllDetectedTokensFlat = createSelector( selectAllDetectedTokensForSelectedAddress, (detectedTokensByChain: { [chainId: string]: Token[] }) => { diff --git a/app/util/networks/index.js b/app/util/networks/index.js index 80b6d9144a2..461f8ea6b87 100644 --- a/app/util/networks/index.js +++ b/app/util/networks/index.js @@ -497,7 +497,5 @@ export const isChainPermissionsFeatureEnabled = export const isPermissionsSettingsV1Enabled = process.env.MM_PERMISSIONS_SETTINGS_V1_ENABLED === 'true'; -export const isPortfolioViewEnabled = process.env.PORTFOLIO_VIEW === 'true'; - -export const isPortfolioViewEnabledFunction = () => +export const isPortfolioViewEnabled = () => process.env.PORTFOLIO_VIEW === 'true'; diff --git a/e2e/specs/settings/fiat-on-testnets.spec.js b/e2e/specs/settings/fiat-on-testnets.spec.js index d70eb7cfb5a..5ab22fa855b 100644 --- a/e2e/specs/settings/fiat-on-testnets.spec.js +++ b/e2e/specs/settings/fiat-on-testnets.spec.js @@ -33,6 +33,7 @@ describe(SmokeAssets('Fiat On Testnets Setting'), () => { // Switch to Sepolia await WalletView.tapNetworksButtonOnNavBar(); + await NetworkListModal.scrollToBottomOfNetworkList(); await NetworkListModal.changeNetworkTo(SEPOLIA); await NetworkEducationModal.tapGotItButton(); diff --git a/patches/@metamask+assets-controllers+45.1.1.patch b/patches/@metamask+assets-controllers+45.1.1.patch index 43cd9a9f607..106e984e11c 100644 --- a/patches/@metamask+assets-controllers+45.1.1.patch +++ b/patches/@metamask+assets-controllers+45.1.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@metamask/assets-controllers/dist/NftController.cjs b/node_modules/@metamask/assets-controllers/dist/NftController.cjs -index 6ccbe9c..f725852 100644 +index 6ccbe9c..49270d6 100644 --- a/node_modules/@metamask/assets-controllers/dist/NftController.cjs +++ b/node_modules/@metamask/assets-controllers/dist/NftController.cjs @@ -13,7 +13,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( @@ -150,7 +150,7 @@ index 6ccbe9c..f725852 100644 } } diff --git a/node_modules/@metamask/assets-controllers/dist/NftController.d.cts b/node_modules/@metamask/assets-controllers/dist/NftController.d.cts -index a34725f..12487d6 100644 +index a34725f..21e9d20 100644 --- a/node_modules/@metamask/assets-controllers/dist/NftController.d.cts +++ b/node_modules/@metamask/assets-controllers/dist/NftController.d.cts @@ -108,6 +108,7 @@ export type NftMetadata = { @@ -161,3 +161,109 @@ index a34725f..12487d6 100644 collection?: Collection; address?: string; attributes?: Attributes[]; +diff --git a/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs b/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs +index c5aa814..83c0664 100644 +--- a/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs ++++ b/node_modules/@metamask/assets-controllers/dist/TokenDetectionController.cjs +@@ -220,50 +220,57 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + this.messagingSystem.subscribe('KeyringController:unlock', async () => { +- __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, true, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this); +- }); +- this.messagingSystem.subscribe('KeyringController:lock', () => { +- __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, false, "f"); +- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_stopPolling).call(this); +- }); +- this.messagingSystem.subscribe('TokenListController:stateChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async ({ tokensChainsCache }) => { +- const isEqualValues = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_compareTokensChainsCache).call(this, tokensChainsCache, __classPrivateFieldGet(this, _TokenDetectionController_tokensChainsCache, "f")); +- if (!isEqualValues) { +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this); +- } +- }); +- this.messagingSystem.subscribe('PreferencesController:stateChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async ({ useTokenDetection }) => { +- const selectedAccount = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_getSelectedAccount).call(this); +- const isDetectionChangedFromPreferences = __classPrivateFieldGet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, "f") !== useTokenDetection; +- __classPrivateFieldSet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, useTokenDetection, "f"); +- if (isDetectionChangedFromPreferences) { +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- }); +- } +- }); +- this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async (selectedAccount) => { +- const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); +- const chainIds = Object.keys(networkConfigurationsByChainId); +- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; +- if (isSelectedAccountIdChanged) { +- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- chainIds, +- }); +- } +- }); ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, true, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { chainIds }); ++ }); ++ this.messagingSystem.subscribe('KeyringController:lock', () => { ++ __classPrivateFieldSet(this, _TokenDetectionController_isUnlocked, false, "f"); ++ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_stopPolling).call(this); ++ }); ++ this.messagingSystem.subscribe('TokenListController:stateChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async ({ tokensChainsCache }) => { ++ const isEqualValues = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_compareTokensChainsCache).call(this, tokensChainsCache, __classPrivateFieldGet(this, _TokenDetectionController_tokensChainsCache, "f")); ++ if (!isEqualValues) { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { chainIds }); ++ } ++ }); ++ this.messagingSystem.subscribe('PreferencesController:stateChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async ({ useTokenDetection }) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const selectedAccount = __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_getSelectedAccount).call(this); ++ const isDetectionChangedFromPreferences = __classPrivateFieldGet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, "f") !== useTokenDetection; ++ __classPrivateFieldSet(this, _TokenDetectionController_isDetectionEnabledFromPreferences, useTokenDetection, "f"); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ if (isDetectionChangedFromPreferences) { ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); ++ this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async (selectedAccount) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; ++ if (isSelectedAccountIdChanged) { ++ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); + }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { + if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { + clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); From da4021c0f72f2c21021dd2fdd1c6551ffd9f1099 Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Tue, 10 Dec 2024 22:06:25 +0100 Subject: [PATCH 04/15] fix: fix flaky test (#12626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fix flaky test ## **Related issues** Fixes: ## **Manual testing steps** 1. CI should be green ## **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. --- e2e/pages/Browser/BrowserView.js | 1 - 1 file changed, 1 deletion(-) diff --git a/e2e/pages/Browser/BrowserView.js b/e2e/pages/Browser/BrowserView.js index 25fe57d72b9..bca647e9332 100644 --- a/e2e/pages/Browser/BrowserView.js +++ b/e2e/pages/Browser/BrowserView.js @@ -178,7 +178,6 @@ class Browser { } async navigateToURL(url) { - await Gestures.waitAndTap(this.clearURLButton); await device.disableSynchronization(); // because animations makes typing into the browser slow await Gestures.typeTextAndHideKeyboard(this.urlInputBoxID, url); From 54e0a8a4dc5de767fc1e0a01bca08b6c586da7b7 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 10 Dec 2024 22:12:45 +0000 Subject: [PATCH 05/15] fix: e2e regression gas api (#12607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR fixes the suggested gas API E2E test. ## **Related issues** Fixes: https://github.com/MetaMask/mobile-planning/issues/2067 ## **Manual testing steps** 1. Bitrise Regression ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] 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. --- e2e/fixtures/fixture-helper.js | 6 ++++-- e2e/fixtures/utils.js | 2 +- .../suggested-gas-api.mock.spec.js} | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) rename e2e/specs/{quarantine/suggestedGasApi.mock.failing.js => confirmations/suggested-gas-api.mock.spec.js} (96%) diff --git a/e2e/fixtures/fixture-helper.js b/e2e/fixtures/fixture-helper.js index e83844fd1e7..b8df26fcb3c 100644 --- a/e2e/fixtures/fixture-helper.js +++ b/e2e/fixtures/fixture-helper.js @@ -6,7 +6,7 @@ import GanacheSeeder from '../../app/util/test/ganache-seeder'; import axios from 'axios'; import path from 'path'; import createStaticServer from '../create-static-server'; -import { getFixturesServerPort, getLocalTestDappPort, getMockServerPort } from './utils'; +import { DEFAULT_MOCKSERVER_PORT, getFixturesServerPort, getLocalTestDappPort, getMockServerPort } from './utils'; import Utilities from '../utils/Utilities'; import { device } from 'detox'; import TestHelpers from '../helpers'; @@ -106,8 +106,10 @@ export async function withFixtures(options, testSuite) { const fixtureServer = new FixtureServer(); let mockServer; - const mockServerPort = getMockServerPort(); + let mockServerPort = DEFAULT_MOCKSERVER_PORT; + if (testSpecificMock) { + mockServerPort = getMockServerPort(); mockServer = await startMockServer(testSpecificMock, mockServerPort); } diff --git a/e2e/fixtures/utils.js b/e2e/fixtures/utils.js index 395c39d73ba..0e8be173fe7 100644 --- a/e2e/fixtures/utils.js +++ b/e2e/fixtures/utils.js @@ -2,7 +2,7 @@ import { DEFAULT_GANACHE_PORT } from '../../app/util/test/ganache'; import { DEFAULT_FIXTURE_SERVER_PORT } from './fixture-server'; import { DEFAULT_DAPP_SERVER_PORT } from './fixture-helper'; -const DEFAULT_MOCKSERVER_PORT = 8000; +export const DEFAULT_MOCKSERVER_PORT = 8000; function transformToValidPort(defaultPort, pid) { // Improve uniqueness by using a simple transformation diff --git a/e2e/specs/quarantine/suggestedGasApi.mock.failing.js b/e2e/specs/confirmations/suggested-gas-api.mock.spec.js similarity index 96% rename from e2e/specs/quarantine/suggestedGasApi.mock.failing.js rename to e2e/specs/confirmations/suggested-gas-api.mock.spec.js index 2f078cf1f3b..e2038a25b55 100644 --- a/e2e/specs/quarantine/suggestedGasApi.mock.failing.js +++ b/e2e/specs/confirmations/suggested-gas-api.mock.spec.js @@ -50,7 +50,7 @@ describe( }); const RECIPIENT = '0x1FDb169Ef12954F20A15852980e1F0C122BfC1D6'; - const AMOUNT = '0.0003'; + const AMOUNT = '0.000003'; const validPrivateKey = Accounts.getAccountPrivateKey(); it('should fallback to legacy gas endpoint & legacy modal when EIP1559 endpoint is down', async () => { @@ -85,7 +85,7 @@ describe( await Assertions.checkIfVisible( TransactionConfirmView.editPriorityLegacyModal, ); - await stopMockServer(); //stop mock server to reinstate suggested gas api service + await stopMockServer(mockServer); //stop mock server to reinstate suggested gas api service await Assertions.checkIfVisible( TransactionConfirmView.editPriorityFeeSheetContainer, 30000, From 226107b8114721b5ca62630c48f11764636ace2a Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Tue, 10 Dec 2024 19:15:47 -0700 Subject: [PATCH 06/15] fix: hide tokens without balance for multichain (#12630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Fixed an issue where multi chain balance was not being calculated correctly for hide zero tokens settings. Also fixed an issue where the ticker was `undefined` in assets overview when it was a non-native token. These fixes belong to a feature that is hidden behind a feature flag `PORTFOLIO_VIEW` ## **Related issues** Fixes: ## **Manual testing steps** 1. Goto settings and turn on "Hide Tokens Without Balance" 2. Observe that your zero tokens and tokens with no conversion rate disappear ## **Screenshots/Recordings** ### Zero Balance | Before | After | |:---:|:---:| |![zero_balance_before](https://github.com/user-attachments/assets/21bde196-951f-447e-9de0-ce214cee4a1f)|![zero_balance_after](https://github.com/user-attachments/assets/a92aaa8f-5c08-4c45-bd0d-0fdaff89ca74)| ### Ticker | Before | After | |:---:|:---:| |![ticker_before](https://github.com/user-attachments/assets/15776130-ea3e-4cc9-901d-61d591dbaab9)|![ticker_after](https://github.com/user-attachments/assets/b5384a6d-47da-4c92-9b63-2709de52c66d)| ### **Before** NA ### **After** NA ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/UI/AssetOverview/AssetOverview.tsx | 2 +- .../__snapshots__/AssetOverview.test.tsx.snap | 2 +- app/components/UI/Tokens/index.tsx | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index dcb345b1904..cbcd738dca6 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -383,7 +383,7 @@ const AssetOverview: React.FC = ({ : `${balance} ${asset.symbol}`; } } else { - mainBalance = `${balance} ${asset.ticker}`; + mainBalance = `${balance} ${asset.isETH ? asset.ticker : asset.symbol}`; secondaryBalance = exchangeRate ? asset.balanceFiat : ''; } diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index 4ec605677fb..c1eba443d22 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -2253,7 +2253,7 @@ exports[`AssetOverview should render correctly when portfolio view is enabled 1` } testID="main-balance-test-id" > - 0 undefined + 0 ETH
diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index b4d895b45f9..11d8401521c 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -146,12 +146,14 @@ const Tokens: React.FC = ({ tokens }) => { // First filter zero balance tokens if setting is enabled const tokensToDisplay = hideZeroBalanceTokens - ? allTokens.filter( - (curToken) => - !isZero(curToken.balance) || - curToken.isNative || - curToken.isStaked, - ) + ? allTokens.filter((curToken) => { + const multiChainTokenBalances = + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + curToken.chainId as Hex + ]; + const balance = multiChainTokenBalances?.[curToken.address as Hex]; + return !isZero(balance) || curToken.isNative || curToken.isStaked; + }) : allTokens; // Then apply network filters From 259faa356165b318b35faf8c9d12ad5077b6fe0a Mon Sep 17 00:00:00 2001 From: Salim TOUBAL Date: Wed, 11 Dec 2024 03:32:05 +0100 Subject: [PATCH 07/15] feat: activate portfolio view (#12507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR turns on the Portfolio view feature flag. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **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. --------- Co-authored-by: vinnyhoward Co-authored-by: sahar-fehri Co-authored-by: Nicholas Gambino Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com> --- .js.env.example | 14 +- .../AccountSelector.test.tsx | 13 + .../StakingBalance.test.tsx.snap | 472 +++++++++++++++++- bitrise.yml | 2 +- jest.config.js | 1 + 5 files changed, 495 insertions(+), 7 deletions(-) diff --git a/.js.env.example b/.js.env.example index 1c11f591536..f03063c77fe 100644 --- a/.js.env.example +++ b/.js.env.example @@ -1,10 +1,10 @@ # Sign up and generate your own keys at pubnub.com # Then rename this file to ".js.env" and rebuild the app -# +# # In order for this feature to work properly, you need to # build metamask-extension from source (https://github.com/MetaMask/metamask-extension) # and set your the same values there. -# +# # For more info take a look at https://github.com/MetaMask/metamask-extension/pull/5955 export MM_PUBNUB_SUB_KEY="" @@ -70,6 +70,10 @@ export SEGMENT_FLUSH_EVENT_LIMIT="1" # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" +# Enable Portfolio View +export PORTFOLIO_VIEW="true" + + # Temporary mechanism to enable security alerts API prior to release. export MM_SECURITY_ALERTS_API_ENABLED="true" # Firebase @@ -81,7 +85,7 @@ export FCM_CONFIG_MESSAGING_SENDER_ID="" export FCM_CONFIG_APP_ID="" export GOOGLE_SERVICES_B64_ANDROID="" export GOOGLE_SERVICES_B64_IOS="" -#Notifications Feature Announcements +# Notifications Feature Announcements export FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN= export FEATURES_ANNOUNCEMENTS_SPACE_ID= @@ -96,8 +100,8 @@ export MM_PER_DAPP_SELECTED_NETWORK="" export MM_CHAIN_PERMISSIONS="" -#Multichain feature flag specific to UI changes +# Multichain feature flag specific to UI changes export MM_MULTICHAIN_V1_ENABLED="" -#Permissions Settings feature flag specific to UI changes +# Permissions Settings feature flag specific to UI changes export MM_PERMISSIONS_SETTINGS_V1_ENABLED="" diff --git a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx index ec3a6c860fd..ad88315241f 100644 --- a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx +++ b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx @@ -18,6 +18,9 @@ import { mockNetworkState } from '../../../util/test/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { AccountSelectorListProps } from './AccountSelectorList.types'; +// eslint-disable-next-line import/no-namespace +import * as Utils from '../../hooks/useAccounts/utils'; + const BUSINESS_ACCOUNT = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; const PERSONAL_ACCOUNT = '0xd018538C87232FF95acbCe4870629b75640a78E7'; @@ -125,6 +128,16 @@ const renderComponent = ( describe('AccountSelectorList', () => { beforeEach(() => { + jest.spyOn(Utils, 'getAccountBalances').mockReturnValueOnce({ + balanceETH: '1', + balanceFiat: '$3200.00', + balanceWeiHex: '', + }); + jest.spyOn(Utils, 'getAccountBalances').mockReturnValueOnce({ + balanceETH: '2', + balanceFiat: '$6400.00', + balanceWeiHex: '', + }); onSelectAccount.mockClear(); onRemoveImportedAccount.mockClear(); }); diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap index 8095e5ad753..61fe94e4e3f 100644 --- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap @@ -158,7 +158,477 @@ exports[`StakingBalance render matches snapshot 1`] = ` resizeMode="contain" source={ { - "uri": "MockImage", + "default": { + "uri": "MockImage", + }, + } + } + style={ + { + "height": 32, + "width": 32, + } + } + testID="network-avatar-image" + /> + + + + + + Staked Ethereum + + + + + + + + + + + Unstaking 0.0010 ETH in progress. Come back in a few days to claim it. + + + + + + + + + + You can claim 0.00214 ETH. Once claimed, you'll get ETH back in your wallet. + + + + Claim + ETH + + + + + + + + Unstake + + + + + Stake more + + + + + +`; + +exports[`StakingBalance should match the snapshot when portfolio view is enabled 1`] = ` + + + + + + + + + + + + + + + Date: Wed, 11 Dec 2024 15:15:16 +0000 Subject: [PATCH 08/15] feat: upgrade transaction controller to get incoming transactions using accounts API (#12419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Update `@metamask/transaction-controller` to retrieve incoming transactions using the accounts API rather than Etherscan. Add incoming transaction E2E tests. ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] 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. --- .js.env.example | 1 - app/components/Nav/Main/index.js | 6 +- app/components/UI/NetworkCell/NetworkCell.tsx | 10 +- .../UI/Notification/BaseNotification/index.js | 2 +- app/components/UI/Transactions/index.js | 10 +- app/components/Views/Asset/index.js | 8 +- .../Views/NetworkSelector/NetworkSelector.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 606 ------------------ .../__snapshots__/index.test.tsx.snap | 34 - .../IncomingTransactionsSettings/index.tsx | 24 +- .../NetworksSettings/NetworkSettings/index.js | 62 +- .../Views/Settings/NetworksSettings/index.js | 5 +- .../SecuritySettings.test.tsx.snap | 606 ------------------ .../Views/TransactionsView/index.js | 2 +- app/core/Engine/Engine.ts | 12 +- app/core/NotificationManager.js | 87 +-- e2e/fixtures/fixture-builder.js | 82 ++- e2e/pages/Transactions/ActivitiesView.js | 12 +- .../Transactions/ActivitiesView.selectors.js | 4 + .../wallet/incoming-transactions.spec.js | 202 ++++++ package.json | 2 +- yarn.lock | 8 +- 22 files changed, 391 insertions(+), 1396 deletions(-) create mode 100644 e2e/specs/wallet/incoming-transactions.spec.js diff --git a/.js.env.example b/.js.env.example index f03063c77fe..56c7c1bb865 100644 --- a/.js.env.example +++ b/.js.env.example @@ -10,7 +10,6 @@ export MM_PUBNUB_SUB_KEY="" export MM_PUBNUB_PUB_KEY="" export MM_OPENSEA_KEY="" -export MM_ETHERSCAN_KEY="" export MM_FOX_CODE="EXAMPLE_FOX_CODE" # NOTE: Non-MetaMask only, will need to create an account and generate diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 5330bfdb1ea..b8ba760da0c 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -133,7 +133,7 @@ const Main = (props) => { stopIncomingTransactionPolling(); if (showIncomingTransactionsNetworks[chainId]) { - startIncomingTransactionPolling([networkClientId]); + startIncomingTransactionPolling([chainId]); } }, [chainId, networkClientId, showIncomingTransactionsNetworks]); @@ -178,11 +178,11 @@ const Main = (props) => { removeNotVisibleNotifications(); BackgroundTimer.runBackgroundTimer(async () => { - await updateIncomingTransactions([props.networkClientId]); + await updateIncomingTransactions([props.chainId]); }, AppConstants.TX_CHECK_BACKGROUND_FREQUENCY); } }, - [backgroundMode, removeNotVisibleNotifications, props.networkClientId], + [backgroundMode, removeNotVisibleNotifications, props.chainId], ); const initForceReload = () => { diff --git a/app/components/UI/NetworkCell/NetworkCell.tsx b/app/components/UI/NetworkCell/NetworkCell.tsx index 58ea3d26c5d..a9816e6c86a 100644 --- a/app/components/UI/NetworkCell/NetworkCell.tsx +++ b/app/components/UI/NetworkCell/NetworkCell.tsx @@ -1,23 +1,21 @@ import React from 'react'; import { Switch, ImageSourcePropType } from 'react-native'; -import { ETHERSCAN_SUPPORTED_NETWORKS } from '@metamask/transaction-controller'; import { useStyles } from '../../../component-library/hooks'; import Cell from '../../../component-library/components/Cells/Cell/Cell'; import { CellVariant } from '../../../component-library/components/Cells/Cell'; import { AvatarVariant } from '../../../component-library/components/Avatars/Avatar/Avatar.types'; import { useTheme } from '../../../util/theme'; -import { EtherscanSupportedHexChainId } from '@metamask/preferences-controller'; import styleSheet from './NetworkCell.styles'; +import { Hex } from '@metamask/utils'; -const supportedNetworks = ETHERSCAN_SUPPORTED_NETWORKS; interface NetworkCellProps { name: string; - chainId: EtherscanSupportedHexChainId | keyof typeof supportedNetworks; + chainId: Hex; imageSource: ImageSourcePropType; - secondaryText: string; + secondaryText?: string; showIncomingTransactionsNetworks: Record; toggleEnableIncomingTransactions: ( - chainId: EtherscanSupportedHexChainId, + chainId: Hex, value: boolean, ) => void; testID?: string; diff --git a/app/components/UI/Notification/BaseNotification/index.js b/app/components/UI/Notification/BaseNotification/index.js index a8fe45ea886..8c137a39168 100644 --- a/app/components/UI/Notification/BaseNotification/index.js +++ b/app/components/UI/Notification/BaseNotification/index.js @@ -154,7 +154,7 @@ const getTitle = (status, { nonce, amount, assetType }) => { }; export const getDescription = (status, { amount = null, type = null }) => { - if (amount && typeof amount !== 'object') { + if (amount && typeof amount !== 'object' && type) { return strings(`notifications.${type}_${status}_message`, { amount }); } return strings(`notifications.${status}_message`); diff --git a/app/components/UI/Transactions/index.js b/app/components/UI/Transactions/index.js index 3cc18c5725a..ac5d5155957 100644 --- a/app/components/UI/Transactions/index.js +++ b/app/components/UI/Transactions/index.js @@ -76,6 +76,7 @@ import { } from '../../../util/transaction-controller'; import { selectGasFeeEstimates } from '../../../selectors/confirmTransaction'; import { decGWEIToHexWEI } from '../../../util/conversions'; +import { ActivitiesViewSelectorsIDs } from '../../../../e2e/selectors/Transactions/ActivitiesView.selectors'; const createStyles = (colors, typography) => StyleSheet.create({ @@ -213,10 +214,6 @@ class Transactions extends PureComponent { */ onScrollThroughContent: PropTypes.func, gasFeeEstimates: PropTypes.object, - /** - * ID of the global network client - */ - networkClientId: PropTypes.string, }; static defaultProps = { @@ -352,11 +349,11 @@ class Transactions extends PureComponent { }; onRefresh = async () => { - const { networkClientId } = this.props; + const { chainId } = this.props; this.setState({ refreshing: true }); - await updateIncomingTransactions([networkClientId]); + await updateIncomingTransactions([chainId]); this.setState({ refreshing: false }); }; @@ -791,6 +788,7 @@ class Transactions extends PureComponent { {({ isChartBeingTouched }) => ( { - const { networkClientId } = this.props; + const { chainId } = this.props; this.setState({ refreshing: true }); - await updateIncomingTransactions([networkClientId]); + await updateIncomingTransactions([chainId]); this.setState({ refreshing: false }); }; diff --git a/app/components/Views/NetworkSelector/NetworkSelector.tsx b/app/components/Views/NetworkSelector/NetworkSelector.tsx index 444daa341b1..a0db393144f 100644 --- a/app/components/Views/NetworkSelector/NetworkSelector.tsx +++ b/app/components/Views/NetworkSelector/NetworkSelector.tsx @@ -399,7 +399,7 @@ const NetworkSelector = () => { AccountTrackerController.refresh(); setTimeout(async () => { - await updateIncomingTransactions([clientId]); + await updateIncomingTransactions([networkConfiguration.chainId]); }, 1000); } diff --git a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap index b37f7966a1a..57456e17de5 100644 --- a/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/OnboardingSuccess/OnboardingAssetsSettings/__snapshots__/index.test.tsx.snap @@ -459,7 +459,6 @@ exports[`OnboardingAssetSettings should render correctly 1`] = ` "variant": "Network", } } - secondaryText="etherscan.io" style={ { "backgroundColor": "#ffffff", @@ -538,22 +537,6 @@ exports[`OnboardingAssetSettings should render correctly 1`] = ` > Ethereum Mainnet
- - etherscan.io - Linea - - lineascan.build - - - - - - G - - - - - Goerli - - - etherscan.io - - - - - - - - - - - - - - - Sepolia - - - etherscan.io - - - - - - - - - - - - L - - - - - Linea Goerli - - - lineascan.build - - - - - - - - - - - - - - - Linea Sepolia - - - lineascan.build - - - - - - - Mainnet - - etherscan.io - Linea Mainnet - - lineascan.build - { const { styles } = useStyles(styleSheet, {}); @@ -40,14 +41,12 @@ const IncomingTransactionsSettings = () => { const networkConfigurations = useSelector(selectNetworkConfigurations); - const supportedNetworks = ETHERSCAN_SUPPORTED_NETWORKS; - const toggleEnableIncomingTransactions = ( - hexChainId: EtherscanSupportedHexChainId, + hexChainId: Hex, value: boolean, ) => { PreferencesController.setEnableNetworkIncomingTransactions( - hexChainId, + hexChainId as EtherscanSupportedHexChainId, value, ); }; @@ -73,7 +72,10 @@ const IncomingTransactionsSettings = () => { chainId, defaultRpcEndpointIndex, }: NetworkConfiguration) => { - if (!chainId || !Object.keys(supportedNetworks).includes(chainId)) + if ( + !chainId || + !INCOMING_TRANSACTIONS_SUPPORTED_CHAIN_IDS.includes(chainId) + ) return null; const rpcUrl = rpcEndpoints[defaultRpcEndpointIndex].url; @@ -88,8 +90,6 @@ const IncomingTransactionsSettings = () => { //@ts-expect-error - The utils/network file is still JS and this function expects a networkType, and should be optional const image = getNetworkImageSource({ chainId: chainId?.toString() }); - const secondaryText = - supportedNetworks[chainId as keyof typeof supportedNetworks].domain; return ( { name={name} chainId={chainId as EtherscanSupportedHexChainId} imageSource={image} - secondaryText={secondaryText} showIncomingTransactionsNetworks={showIncomingTransactionsNetworks} toggleEnableIncomingTransactions={toggleEnableIncomingTransactions} testID={testId} @@ -113,16 +112,15 @@ const IncomingTransactionsSettings = () => { const getOtherNetworks = () => getAllNetworks().slice(2); return getOtherNetworks().map((networkType) => { const { name, imageSource, chainId } = NetworksTyped[networkType]; + if (!chainId) return null; - const secondaryText = - supportedNetworks[chainId as keyof typeof supportedNetworks].domain; + return ( diff --git a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js index 21c7659023a..f937c9130ac 100644 --- a/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js +++ b/app/components/Views/Settings/NetworksSettings/NetworkSettings/index.js @@ -541,7 +541,7 @@ export class NetworkSettings extends PureComponent { editable = false; blockExplorerUrl = networkConfigurations?.[chainId]?.blockExplorerUrls[ - networkConfigurations?.[chainId]?.defaultBlockExplorerUrlIndex + networkConfigurations?.[chainId]?.defaultBlockExplorerUrlIndex ]; rpcUrl = networkConfigurations?.[chainId]?.rpcEndpoints[ @@ -563,13 +563,13 @@ export class NetworkSettings extends PureComponent { ({ rpcEndpoints, defaultRpcEndpointIndex }) => rpcEndpoints[defaultRpcEndpointIndex].url === networkTypeOrRpcUrl || rpcEndpoints[defaultRpcEndpointIndex].networkClientId === - networkTypeOrRpcUrl, + networkTypeOrRpcUrl, ); nickname = networkConfiguration?.name; chainId = networkConfiguration?.chainId; blockExplorerUrl = networkConfiguration?.blockExplorerUrls[ - networkConfiguration?.defaultBlockExplorerUrlIndex + networkConfiguration?.defaultBlockExplorerUrlIndex ]; ticker = networkConfiguration?.nativeCurrency; editable = true; @@ -854,8 +854,8 @@ export class NetworkSettings extends PureComponent { networkConfig, existingNetwork.chainId === chainId ? { - replacementSelectedRpcEndpointIndex: indexRpc, - } + replacementSelectedRpcEndpointIndex: indexRpc, + } : undefined, ); } else { @@ -867,8 +867,8 @@ export class NetworkSettings extends PureComponent { isCustomMainnet ? navigation.navigate('OptinMetrics') : shouldNetworkSwitchPopToWallet - ? navigation.navigate('WalletView') - : navigation.goBack(); + ? navigation.navigate('WalletView') + : navigation.goBack(); }; /** @@ -1534,13 +1534,13 @@ export class NetworkSettings extends PureComponent { const { networkClientId } = networkConfigurations?.rpcEndpoints?.[ - networkConfigurations.defaultRpcEndpointIndex + networkConfigurations.defaultRpcEndpointIndex ] ?? {}; NetworkController.setActiveNetwork(networkClientId); setTimeout(async () => { - await updateIncomingTransactions([networkClientId]); + await updateIncomingTransactions([CHAIN_IDS.MAINNET]); }, 1000); }; @@ -1950,15 +1950,15 @@ export class NetworkSettings extends PureComponent { // Conditionally include secondaryText only if rpcName exists {...(rpcName ? { - secondaryText: - hideKeyFromUrl(rpcUrl) ?? - hideKeyFromUrl( - networkConfigurations?.[chainId]?.rpcEndpoints?.[ - networkConfigurations?.[chainId] - ?.defaultRpcEndpointIndex - ]?.url, - ), - } + secondaryText: + hideKeyFromUrl(rpcUrl) ?? + hideKeyFromUrl( + networkConfigurations?.[chainId]?.rpcEndpoints?.[ + networkConfigurations?.[chainId] + ?.defaultRpcEndpointIndex + ]?.url, + ), + } : {})} isSelected={false} withAvatar={false} @@ -1993,17 +1993,17 @@ export class NetworkSettings extends PureComponent { {!isNetworkUiRedesignEnabled() ? warningRpcUrl && ( - - {warningRpcUrl} - - ) + + {warningRpcUrl} + + ) : null} @@ -2268,7 +2268,7 @@ export class NetworkSettings extends PureComponent { ) : null} {isNetworkUiRedesignEnabled() && - showMultiBlockExplorerAddModal.isVisible ? ( + showMultiBlockExplorerAddModal.isVisible ? ( 0 ? styles.sheet : styles.sheetSmall @@ -2478,7 +2478,7 @@ export class NetworkSettings extends PureComponent { > {(isNetworkUiRedesignEnabled() && !shouldShowPopularNetworks) || - networkTypeOrRpcUrl ? ( + networkTypeOrRpcUrl ? ( this.customNetwork() ) : ( StyleSheet.create({ @@ -191,7 +192,7 @@ class NetworksSettings extends PureComponent { NetworkController.setProviderType(MAINNET); setTimeout(async () => { - await updateIncomingTransactions([MAINNET]); + await updateIncomingTransactions([CHAIN_IDS.MAINNET]); }, 1000); }; @@ -451,7 +452,7 @@ class NetworksSettings extends PureComponent { (networkConfiguration, i) => { const defaultRpcEndpoint = networkConfiguration.rpcEndpoints[ - networkConfiguration.defaultRpcEndpointIndex + networkConfiguration.defaultRpcEndpointIndex ]; const { color, name, url, chainId } = { name: networkConfiguration.name || defaultRpcEndpoint.url, 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 8f218fb0c8b..8cd3a457694 100644 --- a/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap +++ b/app/components/Views/Settings/SecuritySettings/__snapshots__/SecuritySettings.test.tsx.snap @@ -1563,7 +1563,6 @@ exports[`SecuritySettings should render correctly 1`] = ` "variant": "Network", } } - secondaryText="etherscan.io" style={ { "backgroundColor": "#ffffff", @@ -1642,22 +1641,6 @@ exports[`SecuritySettings should render correctly 1`] = ` > Ethereum Mainnet - - etherscan.io - Linea - - lineascan.build - - - - - - G - - - - - Goerli - - - etherscan.io - - - - - - - - - - - - - - - Sepolia - - - etherscan.io - - - - - - - - - - - - L - - - - - Linea Goerli - - - lineascan.build - - - - - - - - - - - - - - - Linea Sepolia - - - lineascan.build - - - - - - - { - NotificationManager.gotIncomingTransaction(blockNumber); + 'TransactionController:incomingTransactionsReceived', + (incomingTransactions: TransactionMeta[]) => { + NotificationManager.gotIncomingTransaction(incomingTransactions); }, ); @@ -1666,10 +1666,12 @@ export class Engine { startPolling() { const { NetworkController, TransactionController } = this.context; - const networkClientId = getGlobalNetworkClientId(NetworkController); + const chainId = getGlobalChainId(NetworkController); + + TransactionController.stopIncomingTransactionPolling(); // leaving the reference of TransactionController here, rather than importing it from utils to avoid circular dependency - TransactionController.startIncomingTransactionPolling([networkClientId]); + TransactionController.startIncomingTransactionPolling([chainId]); } configureControllersOnNetworkChange() { diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 013a49ebce9..9f068dbac6f 100644 --- a/app/core/NotificationManager.js +++ b/app/core/NotificationManager.js @@ -9,13 +9,14 @@ import NotificationsService from '../util/notifications/services/NotificationSer import { NotificationTransactionTypes, ChannelId } from '../util/notifications'; import { safeToChecksumAddress, formatAddress } from '../util/address'; import ReviewManager from './ReviewManager'; -import { selectChainId, selectTicker } from '../selectors/networkController'; +import { selectTicker } from '../selectors/networkController'; import { store } from '../store'; -import { useSelector } from 'react-redux'; import { getTicker } from '../../app/util/transactions'; import { updateTransaction } from '../../app/util/transaction-controller'; import { SmartTransactionStatuses } from '@metamask/smart-transactions-controller/dist/types'; +import Logger from '../util/Logger'; +import { TransactionStatus } from '@metamask/transaction-controller'; export const constructTitleAndMessage = (notification) => { let title, message; switch (notification.type) { @@ -431,56 +432,60 @@ class NotificationManager { /** * Generates a notification for an incoming transaction */ - gotIncomingTransaction = async (lastBlock) => { - const { - AccountTrackerController, - TransactionController, - AccountsController, - } = Engine.context; - const selectedInternalAccount = AccountsController.getSelectedAccount(); - const selectedInternalAccountChecksummedAddress = safeToChecksumAddress( - selectedInternalAccount.address, - ); + gotIncomingTransaction = async (incomingTransactions) => { + try { + const { + AccountTrackerController, + AccountsController, + } = Engine.context; + + const selectedInternalAccount = AccountsController.getSelectedAccount(); - const chainId = selectChainId(store.getState()); - const ticker = useSelector(selectTicker); + const selectedInternalAccountChecksummedAddress = safeToChecksumAddress( + selectedInternalAccount.address, + ); - /// Find the incoming TX - const transactions = TransactionController.getTransactions(); + const ticker = selectTicker(store.getState()); - // If a TX has been confirmed more than 10 min ago, it's considered old - const oldestTimeAllowed = Date.now() - 1000 * 60 * 10; + // If a TX has been confirmed more than 10 min ago, it's considered old + const oldestTimeAllowed = Date.now() - 1000 * 60 * 10; - if (transactions.length) { - const txs = transactions - .reverse() + const filteredTransactions = incomingTransactions.reverse() .filter( (tx) => safeToChecksumAddress(tx.txParams?.to) === selectedInternalAccountChecksummedAddress && safeToChecksumAddress(tx.txParams?.from) !== - selectedInternalAccountChecksummedAddress && - tx.chainId === chainId && - tx.status === 'confirmed' && - lastBlock <= parseInt(tx.blockNumber, 10) && + selectedInternalAccountChecksummedAddress && + tx.status === TransactionStatus.confirmed && tx.time > oldestTimeAllowed, ); - if (txs.length > 0) { - this._showNotification({ - type: 'received', - transaction: { - nonce: `${hexToBN(txs[0].txParams.nonce).toString()}`, - amount: `${renderFromWei(hexToBN(txs[0].txParams.value))}`, - id: txs[0]?.id, - assetType: getTicker(ticker), - }, - autoHide: true, - duration: 7000, - }); + + if (!filteredTransactions.length) { + return; } + + const nonce = hexToBN(filteredTransactions[0].txParams.nonce).toString(); + const amount = renderFromWei(hexToBN(filteredTransactions[0].txParams.value)); + const id = filteredTransactions[0]?.id; + + this._showNotification({ + type: 'received', + transaction: { + nonce, + amount, + id, + assetType: getTicker(ticker), + }, + autoHide: true, + duration: 7000, + }); + + // Update balance upon detecting a new incoming transaction + AccountTrackerController.refresh(); + } catch (error) { + Logger.log('Notifications', 'Error while processing incoming transaction', error); } - // Update balance upon detecting a new incoming transaction - AccountTrackerController.refresh(); }; } @@ -512,8 +517,8 @@ export default { setTransactionToView(id) { return instance?.setTransactionToView(id); }, - gotIncomingTransaction(lastBlock) { - return instance?.gotIncomingTransaction(lastBlock); + gotIncomingTransaction(incomingTransactions) { + return instance?.gotIncomingTransaction(incomingTransactions); }, showSimpleNotification(data) { return instance?.showSimpleNotification(data); diff --git a/e2e/fixtures/fixture-builder.js b/e2e/fixtures/fixture-builder.js index 383fa1d01d5..024f90a6c1a 100644 --- a/e2e/fixtures/fixture-builder.js +++ b/e2e/fixtures/fixture-builder.js @@ -3,6 +3,10 @@ import { getGanachePort } from './utils'; import { merge } from 'lodash'; import { CustomNetworks, PopularNetworksList } from '../resources/networks.e2e'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +export const DEFAULT_FIXTURE_ACCOUNT = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; + const DAPP_URL = 'localhost'; /** @@ -59,18 +63,18 @@ class FixtureBuilder { backgroundState: { AccountTrackerController: { accounts: { - '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3': { + [DEFAULT_FIXTURE_ACCOUNT]: { balance: '0x0', }, }, accountsByChainId: { 64: { - '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3': { + [DEFAULT_FIXTURE_ACCOUNT]: { balance: '0x0', }, }, 1: { - '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3': { + [DEFAULT_FIXTURE_ACCOUNT]: { balance: '0x0', }, }, @@ -134,7 +138,7 @@ class FixtureBuilder { '{"cipher":"ynNI8tAH4fcpmXo8S88A/3T3Dd1w0LY5ftpL59gW0ObYxovgFhrtKpRe/WD7WU42KwGBNKVicB9W9at4ePgOJGS6IMWr//C3jh0vKQTabkDzDy1ZfSvztRxGpVjmrnU3fC5B0eq/MBMSrgu8Bww309pk5jghyRfzp9YsG0ONo1CXUm2brQo/eRve7i9aDbiGXiEK0ch0BO7AvZPGMhHtYRrrOro4QrDVHGUgAF5SA1LD4dv/2AB8ctHwn4YbUmICieqlhJhprx3CNOJ086g7vPQOr21T4IbvtTumFaTibfoD3GWHQo11CvE04z3cN3rRERriP7bww/tZOe8OAMFGWANkmOJHwPPwEo1NBr6w3GD2VObEmqNhXeNc6rrM23Vm1JU40Hl+lVKubnbT1vujdGLmOpDY0GdekscQQrETEQJfhKlXIT0wwyPoLwR+Ja+GjyOhBr0nfWVoVoVrcTUwAk5pStBMt+5OwDRpP29L1+BL9eMwDgKpjVXRTh4MGagKYmFc6eKDf6jV0Yt9pG+jevv5IuyhwX0TRtfQCGgRTtS7oxhDQPxGqu01rr+aI7vGMfRQpaKEEXEWVmMaqCmktyUV35evK9h/xv1Yif00XBll55ShxN8t2/PnATvZxFKQfjJe5f/monbwf8rpfXHuFoh8M9hzjbcS5eh/TPYZZu1KltpeHSIAh5C+4aFyZw0e1DeAg/wdRO3PhBrVztsHSyISHlRdfEyw7QF4Lemr++2MVR1dTxS2I5mUEHjh+hmp64euH1Vb/RUppXlmE8t1RYYXfcsF2DlRwPswP739E/EpVtY3Syf/zOTyHyrOJBldzw22sauIzt8Q5Fe5qA/hGRWiejjK31P/P5j7wEKY7vrOJB1LWNXHSuSjffx9Ai9E","iv":"d5dc0252424ac0c08ca49ef320d09569","salt":"feAPSGdL4R2MVj2urJFl4A==","lib":"original"}', keyrings: [ { - accounts: ['0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'], + accounts: [DEFAULT_FIXTURE_ACCOUNT], index: 0, type: 'HD Key Tree', }, @@ -244,7 +248,7 @@ class FixtureBuilder { internalAccounts: { accounts: { '4d7a5e0b-b261-4aed-8126-43972b0fa0a1': { - address: '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3', + address: DEFAULT_FIXTURE_ACCOUNT, id: '4d7a5e0b-b261-4aed-8126-43972b0fa0a1', metadata: { name: 'Account 1', @@ -270,15 +274,15 @@ class FixtureBuilder { PreferencesController: { featureFlags: {}, identities: { - '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3': { - address: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + [DEFAULT_FIXTURE_ACCOUNT]: { + address: DEFAULT_FIXTURE_ACCOUNT, name: 'Account 1', importTime: 1684232000456, }, }, ipfsGateway: 'https://dweb.link/ipfs/', lostIdentities: {}, - selectedAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + selectedAddress: DEFAULT_FIXTURE_ACCOUNT, useTokenDetection: true, useNftDetection: true, displayNftMedia: true, @@ -291,15 +295,15 @@ class FixtureBuilder { featureFlags: {}, frequentRpcList: [], identities: { - '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3': { - address: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + [DEFAULT_FIXTURE_ACCOUNT]: { + address: DEFAULT_FIXTURE_ACCOUNT, name: 'Account 1', importTime: 1684232000456, }, }, ipfsGateway: 'https://dweb.link/ipfs/', lostIdentities: {}, - selectedAddress: '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3', + selectedAddress: DEFAULT_FIXTURE_ACCOUNT, useTokenDetection: true, useNftDetection: false, displayNftMedia: true, @@ -692,9 +696,8 @@ class FixtureBuilder { const { providerConfig } = data; // Generate a unique key for the new network client ID - const newNetworkClientId = `networkClientId${ - Object.keys(networkController.networkConfigurationsByChainId).length + 1 - }`; + const newNetworkClientId = `networkClientId${Object.keys(networkController.networkConfigurationsByChainId).length + 1 + }`; // Define the network configuration const networkConfig = { @@ -746,7 +749,7 @@ class FixtureBuilder { caveats: [ { type: 'restrictReturnedAccounts', - value: ['0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'], + value: [DEFAULT_FIXTURE_ACCOUNT], }, ], date: 1664388714636, @@ -815,10 +818,9 @@ class FixtureBuilder { const fixtures = this.fixture.state.engine.backgroundState; // Generate a unique key for the new network client ID - const newNetworkClientId = `networkClientId${ - Object.keys(fixtures.NetworkController.networkConfigurationsByChainId) - .length + 1 - }`; + const newNetworkClientId = `networkClientId${Object.keys(fixtures.NetworkController.networkConfigurationsByChainId) + .length + 1 + }`; // Define the Ganache network configuration const ganacheNetworkConfig = { @@ -854,10 +856,9 @@ class FixtureBuilder { const sepoliaConfig = CustomNetworks.Sepolia.providerConfig; // Generate a unique key for the new network client ID - const newNetworkClientId = `networkClientId${ - Object.keys(fixtures.NetworkController.networkConfigurationsByChainId) - .length + 1 - }`; + const newNetworkClientId = `networkClientId${Object.keys(fixtures.NetworkController.networkConfigurationsByChainId) + .length + 1 + }`; // Define the Sepolia network configuration const sepoliaNetworkConfig = { @@ -907,9 +908,8 @@ class FixtureBuilder { } = network.providerConfig; // Generate a unique key for the new network client ID - const newNetworkClientId = `networkClientId${ - Object.keys(networkConfigurationsByChainId).length + 1 - }`; + const newNetworkClientId = `networkClientId${Object.keys(networkConfigurationsByChainId).length + 1 + }`; // Define the network configuration const networkConfig = { @@ -970,7 +970,7 @@ class FixtureBuilder { keyrings: [ { type: 'HD Key Tree', - accounts: ['0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'], + accounts: [DEFAULT_FIXTURE_ACCOUNT], }, { type: 'Simple Key Pair', @@ -983,6 +983,34 @@ class FixtureBuilder { return this; } + withTokens(tokens) { + merge(this.fixture.state.engine.backgroundState.TokensController, { + allTokens: { + [CHAIN_IDS.MAINNET]: { + [DEFAULT_FIXTURE_ACCOUNT]: tokens, + } + } + }); + return this; + } + + withIncomingTransactionPreferences(incomingTransactionPreferences) { + merge( + this.fixture.state.engine.backgroundState.PreferencesController, + { + showIncomingTransactions: incomingTransactionPreferences, + }, + ); + return this; + } + + withTransactions(transactions) { + merge(this.fixture.state.engine.backgroundState.TransactionController, { + transactions, + }); + return this; + } + /** * Build and return the fixture object. * @returns {Object} - The built fixture object. diff --git a/e2e/pages/Transactions/ActivitiesView.js b/e2e/pages/Transactions/ActivitiesView.js index 1b88464414c..089bb7f7839 100644 --- a/e2e/pages/Transactions/ActivitiesView.js +++ b/e2e/pages/Transactions/ActivitiesView.js @@ -1,4 +1,4 @@ -import { ActivitiesViewSelectorsText } from '../../selectors/Transactions/ActivitiesView.selectors'; +import { ActivitiesViewSelectorsIDs, ActivitiesViewSelectorsText } from '../../selectors/Transactions/ActivitiesView.selectors'; import Matchers from '../../utils/Matchers'; import Gestures from '../../utils/Gestures'; @@ -7,6 +7,12 @@ class ActivitiesView { return Matchers.getElementByText(ActivitiesViewSelectorsText.TITLE); } + get container() { + return Matchers.getElementByID( + ActivitiesViewSelectorsIDs.CONTAINER, + ); + } + generateSwapActivityLabel(sourceToken, destinationToken) { let title = ActivitiesViewSelectorsText.SWAP; title = title.replace('{{sourceToken}}', sourceToken); @@ -24,6 +30,10 @@ class ActivitiesView { const element = this.swapActivity(sourceToken, destinationToken); await Gestures.waitAndTap(element); } + + async swipeDown() { + await Gestures.swipe(this.container, 'down', 'slow', 0.5); + } } export default new ActivitiesView(); diff --git a/e2e/selectors/Transactions/ActivitiesView.selectors.js b/e2e/selectors/Transactions/ActivitiesView.selectors.js index 3f352595387..50a3cb95384 100644 --- a/e2e/selectors/Transactions/ActivitiesView.selectors.js +++ b/e2e/selectors/Transactions/ActivitiesView.selectors.js @@ -4,6 +4,10 @@ function getSentUnitMessage(unit) { return enContent.transactions.sent_unit.replace('{{unit}}', unit); } +export const ActivitiesViewSelectorsIDs = { + CONTAINER: 'transactions-container', +}; + export const ActivitiesViewSelectorsText = { CONFIRM_TEXT: enContent.transaction.confirmed, INCREASE_ALLOWANCE_METHOD: enContent.transactions.increase_allowance, diff --git a/e2e/specs/wallet/incoming-transactions.spec.js b/e2e/specs/wallet/incoming-transactions.spec.js new file mode 100644 index 00000000000..2cc5adf82d7 --- /dev/null +++ b/e2e/specs/wallet/incoming-transactions.spec.js @@ -0,0 +1,202 @@ +'use strict'; +import { SmokeCore } from '../../tags'; +import TestHelpers from '../../helpers'; +import { loginToApp } from '../../viewHelper'; +import Assertions from '../../utils/Assertions'; +import { startMockServer, stopMockServer } from '../../api-mocking/mock-server'; +import { withFixtures } from '../../fixtures/fixture-helper'; +import FixtureBuilder, { DEFAULT_FIXTURE_ACCOUNT } from '../../fixtures/fixture-builder'; +import ActivitiesView from '../../pages/Transactions/ActivitiesView'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import ToastModal from '../../pages/wallet/ToastModal'; + +const TOKEN_SYMBOL_MOCK = 'ABC'; +const TOKEN_ADDRESS_MOCK = '0x123'; + +const RESPONSE_STANDARD_MOCK = { + hash: '0x123456', + timestamp: new Date().toISOString(), + chainId: 1, + blockNumber: 1, + blockHash: '0x2', + gas: 1, + gasUsed: 1, + gasPrice: '1', + effectiveGasPrice: '1', + nonce: 1, + cumulativeGasUsed: 1, + methodId: null, + value: '1230000000000000000', + to: DEFAULT_FIXTURE_ACCOUNT.toLowerCase(), + from: '0x2', + isError: false, + valueTransfers: [], +}; + +const RESPONSE_STANDARD_2_MOCK = { + ...RESPONSE_STANDARD_MOCK, + timestamp: new Date().toISOString(), + hash: '0x2', + value: '2340000000000000000', +}; + +const RESPONSE_TOKEN_TRANSFER_MOCK = { + ...RESPONSE_STANDARD_MOCK, + to: '0x2', + valueTransfers: [ + { + contractAddress: TOKEN_ADDRESS_MOCK, + decimal: 18, + symbol: TOKEN_SYMBOL_MOCK, + from: '0x2', + to: DEFAULT_FIXTURE_ACCOUNT.toLowerCase(), + amount: '4560000000000000000', + }, + ], +}; + +const RESPONSE_OUTGOING_TRANSACTION_MOCK = { + ...RESPONSE_STANDARD_MOCK, + to: '0x2', + from: DEFAULT_FIXTURE_ACCOUNT.toLowerCase(), +}; + +function mockAccountsApi(transactions) { + return { + urlEndpoint: `https://accounts.api.cx.metamask.io/v1/accounts/${DEFAULT_FIXTURE_ACCOUNT}/transactions?networks=0x1&sortDirection=ASC`, + response: { + data: transactions ?? [RESPONSE_STANDARD_MOCK, RESPONSE_STANDARD_2_MOCK], + pageInfo: { + count: 2, + hasNextPage: false + } + }, + responseCode: 200, + }; +} + +describe(SmokeCore('Incoming Transactions'), () => { + + beforeAll(async () => { + jest.setTimeout(2500000); + await TestHelpers.reverseServerPort(); + }); + + it('displays standard incoming transaction', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + testSpecificMock: { + GET: [mockAccountsApi()] + } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await Assertions.checkIfTextIsDisplayed('Received ETH'); + await Assertions.checkIfTextIsDisplayed(/.*1\.23 ETH.*/); + await Assertions.checkIfTextIsDisplayed(/.*2\.34 ETH.*/); + } + ); + }); + + it('displays incoming token transfers', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().withTokens([{ + address: TOKEN_ADDRESS_MOCK, decimals: 18, symbol: TOKEN_SYMBOL_MOCK + }]).build(), + restartDevice: true, + testSpecificMock: { GET: [mockAccountsApi([RESPONSE_TOKEN_TRANSFER_MOCK])] } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await Assertions.checkIfTextIsDisplayed('Received ABC'); + await Assertions.checkIfTextIsDisplayed(/.*4\.56 ABC.*/); + } + ); + }); + + it('displays outgoing transactions', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + testSpecificMock: { GET: [mockAccountsApi([RESPONSE_OUTGOING_TRANSACTION_MOCK])] } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await Assertions.checkIfTextIsDisplayed('Sent ETH'); + await Assertions.checkIfTextIsDisplayed(/.*1\.23 ETH.*/); + } + ); + }); + + it('displays nothing if incoming transactions disabled', async () => { + await withFixtures( + { + fixture: new FixtureBuilder() + .withIncomingTransactionPreferences({ + '0x1': false + }) + .build(), + restartDevice: true, + testSpecificMock: { GET: [mockAccountsApi()] } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await TestHelpers.delay(2000); + await Assertions.checkIfTextIsNotDisplayed('Received ETH'); + } + ); + }); + + it('displays nothing if incoming transaction is a duplicate', async () => { + await withFixtures( + { + fixture: new FixtureBuilder() + .withTransactions([{ + hash: RESPONSE_STANDARD_MOCK.hash, + txParams: { + from: RESPONSE_STANDARD_MOCK.from + } + }]) + .build(), + restartDevice: true, + testSpecificMock: { GET: [mockAccountsApi([RESPONSE_STANDARD_MOCK])] } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await TestHelpers.delay(2000); + await Assertions.checkIfTextIsNotDisplayed('Received ETH'); + } + ); + }); + + it('displays notification', async () => { + await withFixtures( + { + fixture: new FixtureBuilder() + .build(), + restartDevice: true, + testSpecificMock: { GET: [mockAccountsApi()] } + }, + async () => { + await loginToApp(); + await TabBarComponent.tapActivity(); + await ActivitiesView.swipeDown(); + await Assertions.checkIfElementToHaveText(await ToastModal.notificationTitle, 'You received 1.23 ETH'); + } + ); + }); +}); diff --git a/package.json b/package.json index b11e4906799..5ec83c6c2b4 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "@metamask/stake-sdk": "^0.3.0", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^11.0.0", - "@metamask/transaction-controller": "^41.0.0", + "@metamask/transaction-controller": "^42.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.6", "@notifee/react-native": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index e295783628c..fb78b1da210 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5445,10 +5445,10 @@ resolved "https://registry.yarnpkg.com/@metamask/test-dapp/-/test-dapp-8.9.0.tgz#bac680e8f0007b3a11440f7e311674d6457d37ed" integrity sha512-N/WfmdrzJm+xbpuqJsfMrlrAhiNDsllIpwt9gDDeEKDlQAfJnMtT9xvOvBJbXY7zgMdtGZuD+KY64jNKabbuVQ== -"@metamask/transaction-controller@^41.0.0": - version "41.1.0" - resolved "https://registry.yarnpkg.com/@metamask/transaction-controller/-/transaction-controller-41.1.0.tgz#ad226b3f754750a064175075554b60a0d755a7f3" - integrity sha512-5u7tnl7WOY+Nuw8zXoeIikW7zQSxHduYsXDs2kJSAJo0qaOnFBgER031bqB23TIX2nLh8MR8vPf3Ft5ZBr7/UQ== +"@metamask/transaction-controller@^42.0.0": + version "42.0.0" + resolved "https://registry.yarnpkg.com/@metamask/transaction-controller/-/transaction-controller-42.0.0.tgz#f5c035d018b7f72e4b21757bd075c6863a6301ca" + integrity sha512-lITyvFsrjUhJox5CypaT7B80Bs5VxOziul2dcSBJFrD56vOX46ijq7FelTGbuSegJ+hlc+BUIsSSmhMiSDgHhw== dependencies: "@ethereumjs/common" "^3.2.0" "@ethereumjs/tx" "^4.2.0" From 0b676de3a286a6edbe9885da7e35b53575651f0b Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Wed, 11 Dec 2024 17:17:02 +0100 Subject: [PATCH 09/15] feat: Hide the smart transaction status page if we return a txHash asap (#12622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** We want to hide the smart transaction status page if we return a txHash asap, which we want to do always going forward. Once we verify it works as expected and no fallback is needed, we can remove the STX status page from the codebase. ## **Related issues** Fixes: TXL-538 ## **Manual testing steps** 1. Be on Ethereum Mainnet + smart transactions enabled in Advanced Settings 2. Do a transaction 3. You will not see the STX status page, only a Toast notification as we do for regular (non-STX) transactions ## **Screenshots/Recordings** Smart transaction is submitted: ![image](https://github.com/user-attachments/assets/fc04d632-1254-45e1-8c27-2cc56beb6d22) Smart transaction is completed: ![image](https://github.com/user-attachments/assets/18de1f4c-3adc-446e-b5fe-973c1d48a89b) ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/util/smart-transactions/index.test.ts | 32 +++++++--- app/util/smart-transactions/index.ts | 8 ++- .../smart-publish-hook.test.ts | 63 +++++++++++++++++++ .../smart-transactions/smart-publish-hook.ts | 13 ++-- 4 files changed, 100 insertions(+), 16 deletions(-) diff --git a/app/util/smart-transactions/index.test.ts b/app/util/smart-transactions/index.test.ts index f10f3403108..2bad676e57c 100644 --- a/app/util/smart-transactions/index.test.ts +++ b/app/util/smart-transactions/index.test.ts @@ -283,37 +283,53 @@ describe('Smart Transactions utils', () => { }); describe('getShouldStartFlow', () => { it('returns true for Send transaction', () => { - const res = getShouldStartApprovalRequest(false, true, false, false); + const res = getShouldStartApprovalRequest(false, true, false, false, false); expect(res).toBe(true); }); + it('returns false for Send transaction when mobileReturnTxHashAsap is true', () => { + const res = getShouldStartApprovalRequest(false, true, false, false, true); + expect(res).toBe(false); + }); it('returns true for Dapp transaction', () => { - const res = getShouldStartApprovalRequest(true, false, false, false); + const res = getShouldStartApprovalRequest(true, false, false, false, false); expect(res).toBe(true); }); + it('returns false for Dapp transaction when mobileReturnTxHashAsap is true', () => { + const res = getShouldStartApprovalRequest(true, false, false, false, true); + expect(res).toBe(false); + }); it('returns true for Swap approve transaction', () => { - const res = getShouldStartApprovalRequest(false, false, true, false); + const res = getShouldStartApprovalRequest(false, false, true, false, false); expect(res).toBe(true); }); it('returns false for Swap transaction', () => { - const res = getShouldStartApprovalRequest(false, false, false, true); + const res = getShouldStartApprovalRequest(false, false, false, true, false); expect(res).toBe(false); }); }); describe('getShouldUpdateFlow', () => { it('returns true for Send transaction', () => { - const res = getShouldUpdateApprovalRequest(false, true, false); + const res = getShouldUpdateApprovalRequest(false, true, false, false); expect(res).toBe(true); }); + it('returns false for Send transaction when mobileReturnTxHashAsap is true', () => { + const res = getShouldUpdateApprovalRequest(false, true, false, true); + expect(res).toBe(false); + }); it('returns true for Dapp transaction', () => { - const res = getShouldUpdateApprovalRequest(true, false, false); + const res = getShouldUpdateApprovalRequest(true, false, false, false); expect(res).toBe(true); }); + it('returns false for Dapp transaction when mobileReturnTxHashAsap is true', () => { + const res = getShouldUpdateApprovalRequest(true, false, false, true); + expect(res).toBe(false); + }); it('returns true for Swap transaction', () => { - const res = getShouldUpdateApprovalRequest(false, false, true); + const res = getShouldUpdateApprovalRequest(false, false, true, false); expect(res).toBe(true); }); it('returns false for Swap approve transaction', () => { - const res = getShouldUpdateApprovalRequest(false, false, false); + const res = getShouldUpdateApprovalRequest(false, false, false, false); expect(res).toBe(false); }); }); diff --git a/app/util/smart-transactions/index.ts b/app/util/smart-transactions/index.ts index d831b4ec20f..4b190b5b2a0 100644 --- a/app/util/smart-transactions/index.ts +++ b/app/util/smart-transactions/index.ts @@ -69,14 +69,18 @@ export const getShouldStartApprovalRequest = ( isSend: boolean, isSwapApproveTx: boolean, hasPendingApprovalForSwapApproveTx: boolean, + mobileReturnTxHashAsap: boolean, ): boolean => - isDapp || isSend || isSwapApproveTx || !hasPendingApprovalForSwapApproveTx; + !mobileReturnTxHashAsap && + (isDapp || isSend || isSwapApproveTx || !hasPendingApprovalForSwapApproveTx); export const getShouldUpdateApprovalRequest = ( isDapp: boolean, isSend: boolean, isSwapTransaction: boolean, -): boolean => isDapp || isSend || isSwapTransaction; + mobileReturnTxHashAsap: boolean, +): boolean => + !mobileReturnTxHashAsap && (isDapp || isSend || isSwapTransaction); const waitForSmartTransactionConfirmationDone = ( controllerMessenger: ControllerMessenger, diff --git a/app/util/smart-transactions/smart-publish-hook.test.ts b/app/util/smart-transactions/smart-publish-hook.test.ts index a495cb5d247..3953ad17ee5 100644 --- a/app/util/smart-transactions/smart-publish-hook.test.ts +++ b/app/util/smart-transactions/smart-publish-hook.test.ts @@ -362,6 +362,69 @@ describe('submitSmartTransactionHook', () => { ); }); + it('submits a smart transaction without the smart transaction status page', async () => { + withRequest( + async ({ request, controllerMessenger, submitSignedTransactionsSpy }) => { + request.featureFlags.smartTransactions.mobileReturnTxHashAsap = true; + setImmediate(() => { + controllerMessenger.publish( + 'SmartTransactionsController:smartTransaction', + { + status: 'pending', + statusMetadata: { + minedHash: '', + }, + uuid: 'uuid', + } as SmartTransaction, + ); + + controllerMessenger.publish( + 'SmartTransactionsController:smartTransaction', + { + status: 'success', + statusMetadata: { + minedHash: transactionHash, + }, + uuid: 'uuid', + } as SmartTransaction, + ); + }); + const result = await submitSmartTransactionHook(request); + + expect(result).toEqual({ transactionHash }); + const { txParams, chainId } = request.transactionMeta; + + expect( + request.transactionController.approveTransactionsWithSameNonce, + ).toHaveBeenCalledWith( + [ + { + ...txParams, + maxFeePerGas: '0x2fd8a58d7', + maxPriorityFeePerGas: '0xaa0f8a94', + chainId, + value: undefined, + }, + ], + { hasNonce: true }, + ); + expect(submitSignedTransactionsSpy).toHaveBeenCalledWith({ + signedTransactions: [createSignedTransaction()], + signedCanceledTransactions: [], + txParams, + transactionMeta: request.transactionMeta, + }); + + expect( + request.approvalController.addAndShowApprovalRequest, + ).not.toHaveBeenCalled(); + expect( + request.approvalController.updateRequestState, + ).not.toHaveBeenCalled(); + }, + ); + }); + describe('MM Swaps', () => { it('starts an approval and does not end it if there is an swap tx that requires allowance', async () => { withRequest( diff --git a/app/util/smart-transactions/smart-publish-hook.ts b/app/util/smart-transactions/smart-publish-hook.ts index 2ed010b3d6f..16ecfa2bdf9 100644 --- a/app/util/smart-transactions/smart-publish-hook.ts +++ b/app/util/smart-transactions/smart-publish-hook.ts @@ -94,6 +94,7 @@ class SmartTransactionHook { #shouldStartApprovalRequest: boolean; #shouldUpdateApprovalRequest: boolean; + #mobileReturnTxHashAsap: boolean; constructor(request: SubmitSmartTransactionRequest) { const { @@ -116,6 +117,8 @@ class SmartTransactionHook { this.#chainId = transactionMeta.chainId; this.#txParams = transactionMeta.txParams; this.#controllerMessenger = controllerMessenger; + this.#mobileReturnTxHashAsap = + this.#featureFlags?.smartTransactions?.mobileReturnTxHashAsap ?? false; const { isDapp, @@ -143,11 +146,13 @@ class SmartTransactionHook { this.#isSend, this.#isSwapApproveTx, Boolean(approvalIdForPendingSwapApproveTx), + this.#mobileReturnTxHashAsap, ); this.#shouldUpdateApprovalRequest = getShouldUpdateApprovalRequest( this.#isDapp, this.#isSend, this.#isSwapTransaction, + this.#mobileReturnTxHashAsap, ); } @@ -221,9 +226,7 @@ class SmartTransactionHook { ); throw error; } finally { - const mobileReturnTxHashAsap = - this.#featureFlags?.smartTransactions?.mobileReturnTxHashAsap; - if (!mobileReturnTxHashAsap) { + if (!this.#mobileReturnTxHashAsap) { this.#cleanup(); } } @@ -266,10 +269,8 @@ class SmartTransactionHook { uuid: string, ) => { let transactionHash: string | undefined | null; - const mobileReturnTxHashAsap = - this.#featureFlags?.smartTransactions?.mobileReturnTxHashAsap; - if (mobileReturnTxHashAsap && submitTransactionResponse?.txHash) { + if (this.#mobileReturnTxHashAsap && submitTransactionResponse?.txHash) { transactionHash = submitTransactionResponse.txHash; } else { transactionHash = await this.#waitForTransactionHash({ From 3627a4345e901de360d4d8361b1194a297efb793 Mon Sep 17 00:00:00 2001 From: Andre Pimenta Date: Wed, 11 Dec 2024 19:13:51 +0000 Subject: [PATCH 10/15] build: Add --device flag to yarn start:android (#12645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Currently there is an issue where yarn start:android doesn't open the android emulator automatically if none is already open. Adding this flag will allow devs to choose which emulator to run the app and it will open the specified emulator. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **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. --- scripts/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build.sh b/scripts/build.sh index 4d17078437c..1ea006cd97b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -226,7 +226,7 @@ buildAndroidRun(){ remapEnvVariableLocal prebuild_android #react-native run-android --port=$WATCHER_PORT --variant=prodDebug --active-arch-only - npx expo run:android --no-install --port $WATCHER_PORT --variant 'prodDebug' + npx expo run:android --no-install --port $WATCHER_PORT --variant 'prodDebug' --device } buildAndroidDevBuild(){ From 427415cec9e45128fb880fd42733cc4532e679ab Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Thu, 12 Dec 2024 02:51:21 +0700 Subject: [PATCH 11/15] refactor: de-anonymize insensitive properties of swaps events (#12532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** [Due to a change in mobile event metrics](https://github.com/MetaMask/metamask-mobile/issues/10545), we lost the ability to track conversion for many of swaps mobile funnels. The cause of the issue was that since many of our event did not have a non anonymous component, they could not be tracked through a conversion funnel. These changes update the events so that we pull most of the attributes out of sensitive properties, restoring functionality of event tracking. ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to swaps 2. Complete swap 3. See events in Mixpanel ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Nav/Main/RootRPCMethodsUI.js | 18 ++- app/components/UI/Navbar/index.js | 18 ++- app/components/UI/Swaps/QuotesView.js | 117 ++++++++++++-------- app/components/UI/Swaps/index.js | 2 +- 4 files changed, 101 insertions(+), 54 deletions(-) diff --git a/app/components/Nav/Main/RootRPCMethodsUI.js b/app/components/Nav/Main/RootRPCMethodsUI.js index 31515567b19..d774fd86508 100644 --- a/app/components/Nav/Main/RootRPCMethodsUI.js +++ b/app/components/Nav/Main/RootRPCMethodsUI.js @@ -234,20 +234,34 @@ const RootRPCMethodsUI = (props) => { ); const parameters = { - ...analyticsParams, time_to_mine: timeToMine, estimated_vs_used_gasRatio: estimatedVsUsedGasRatio, quote_vs_executionRatio: quoteVsExecutionRatio, token_to_amount_received: tokenToAmountReceived.toString(), is_smart_transaction: props.shouldUseSmartTransaction, ...smartTransactionMetricsProperties, + available_quotes: analyticsParams.available_quotes, + best_quote_source: analyticsParams.best_quote_source, + chain_id: analyticsParams.chain_id, + custom_slippage: analyticsParams.custom_slippage, + network_fees_USD: analyticsParams.network_fees_USD, + other_quote_selected: analyticsParams.other_quote_selected, + request_type: analyticsParams.request_type, + token_from: analyticsParams.token_from, + token_to: analyticsParams.token_to, + }; + const sensitiveParameters = { + token_from_amount: analyticsParams.token_from_amount, + token_to_amount: analyticsParams.token_to_amount, + network_fees_ETH: analyticsParams.network_fees_ETH, }; Logger.log('Swaps', 'Sending metrics event', event); trackEvent( createEventBuilder(event) - .addSensitiveProperties({ ...parameters }) + .addProperties({ ...parameters }) + .addSensitiveProperties({ ...sensitiveParameters }) .build(), ); } catch (e) { diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index b7763524b3e..8c8d941b7bb 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1740,9 +1740,16 @@ export function getSwapsQuotesNavbar(navigation, route, themeColors) { MetaMetricsEvents.QUOTES_REQUEST_CANCELLED, ) .addProperties({ - ...trade, + token_from: trade.token_from, + token_to: trade.token_to, + request_type: trade.request_type, + custom_slippage: trade.custom_slippage, + chain_id: trade.chain_id, responseTime: new Date().getTime() - quoteBegin, }) + .addSensitiveProperties({ + token_from_amount: trade.token_from_amount, + }) .build(), ); } @@ -1759,9 +1766,16 @@ export function getSwapsQuotesNavbar(navigation, route, themeColors) { MetaMetricsEvents.QUOTES_REQUEST_CANCELLED, ) .addProperties({ - ...trade, + token_from: trade.token_from, + token_to: trade.token_to, + request_type: trade.request_type, + custom_slippage: trade.custom_slippage, + chain_id: trade.chain_id, responseTime: new Date().getTime() - quoteBegin, }) + .addSensitiveProperties({ + token_from_amount: trade.token_from_amount, + }) .build(), ); } diff --git a/app/components/UI/Swaps/QuotesView.js b/app/components/UI/Swaps/QuotesView.js index 494216449fd..9d58c556bec 100644 --- a/app/components/UI/Swaps/QuotesView.js +++ b/app/components/UI/Swaps/QuotesView.js @@ -739,7 +739,7 @@ function SwapsQuotesView({ trackEvent( createEventBuilder(MetaMetricsEvents.GAS_FEES_CHANGED) - .addSensitiveProperties(parameters) + .addProperties(parameters) .build(), ); }, @@ -871,15 +871,7 @@ function SwapsQuotesView({ const parameters = { account_type: getAddressAccountType(selectedAddress), token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, - token_to_amount: fromTokenMinimalUnitString( - selectedQuote.destinationAmount, - destinationToken.decimals, - ), request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', slippage, custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, @@ -895,9 +887,20 @@ function SwapsQuotesView({ chain_id: getDecimalChainId(chainId), is_smart_transaction: shouldUseSmartTransaction, }; + const sensitiveParameters = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + token_to_amount: fromTokenMinimalUnitString( + selectedQuote.destinationAmount, + destinationToken.decimals, + ), + }; trackEvent( createEventBuilder(MetaMetricsEvents.SWAP_STARTED) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveParameters) .build(), ); }, @@ -1157,15 +1160,7 @@ function SwapsQuotesView({ const parameters = { token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, - token_to_amount: fromTokenMinimalUnitString( - selectedQuote.destinationAmount, - destinationToken.decimals, - ), request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', slippage, custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, @@ -1181,9 +1176,20 @@ function SwapsQuotesView({ custom_spend_limit_amount: currentAmount, chain_id: getDecimalChainId(chainId), }; + const sensitiveParameters = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + token_to_amount: fromTokenMinimalUnitString( + selectedQuote.destinationAmount, + destinationToken.decimals, + ), + }; trackEvent( createEventBuilder(MetaMetricsEvents.EDIT_SPEND_LIMIT_OPENED) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveParameters) .build(), ); }, [ @@ -1209,15 +1215,7 @@ function SwapsQuotesView({ if (!selectedQuote || !selectedQuoteValue) return; const parameters = { token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, - token_to_amount: fromTokenMinimalUnitString( - selectedQuote.destinationAmount, - destinationToken.decimals, - ), request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', slippage, custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, @@ -1232,9 +1230,20 @@ function SwapsQuotesView({ available_quotes: allQuotes.length, chain_id: getDecimalChainId(chainId), }; + const sensitiveParameters = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + token_to_amount: fromTokenMinimalUnitString( + selectedQuote.destinationAmount, + destinationToken.decimals, + ), + }; trackEvent( createEventBuilder(MetaMetricsEvents.QUOTES_RECEIVED) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveParameters) .build(), ); }, [ @@ -1258,15 +1267,7 @@ function SwapsQuotesView({ toggleQuotesModal(); const parameters = { token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, - token_to_amount: fromTokenMinimalUnitString( - selectedQuote.destinationAmount, - destinationToken.decimals, - ), request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', slippage, custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, @@ -1281,10 +1282,21 @@ function SwapsQuotesView({ available_quotes: allQuotes.length, chain_id: getDecimalChainId(chainId), }; + const sensitiveParameters = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + token_to_amount: fromTokenMinimalUnitString( + selectedQuote.destinationAmount, + destinationToken.decimals, + ), + }; trackEvent( createEventBuilder(MetaMetricsEvents.ALL_AVAILABLE_QUOTES_OPENED) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveParameters) .build(), ); }, [ @@ -1308,16 +1320,18 @@ function SwapsQuotesView({ (error) => { const data = { token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', slippage, custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, chain_id: getDecimalChainId(chainId), }; + const sensitiveData = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + }; if (error?.key === swapsUtils.SwapsError.QUOTES_EXPIRED_ERROR) { const parameters = { ...data, @@ -1326,7 +1340,8 @@ function SwapsQuotesView({ trackEvent( createEventBuilder(MetaMetricsEvents.QUOTES_TIMED_OUT) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveData) .build(), ); } else if ( @@ -1335,7 +1350,8 @@ function SwapsQuotesView({ const parameters = { ...data }; trackEvent( createEventBuilder(MetaMetricsEvents.NO_QUOTES_AVAILABLE) - .addSensitiveProperties(parameters) + .addProperties(parameters) + .addSensitiveProperties(sensitiveData) .build(), ); } else { @@ -1606,22 +1622,25 @@ function SwapsQuotesView({ setTrackedRequestedQuotes(true); const data = { token_from: sourceToken.symbol, - token_from_amount: fromTokenMinimalUnitString( - sourceAmount, - sourceToken.decimals, - ), token_to: destinationToken.symbol, request_type: hasEnoughTokenBalance ? 'Order' : 'Quote', custom_slippage: slippage !== AppConstants.SWAPS.DEFAULT_SLIPPAGE, chain_id: getDecimalChainId(chainId), }; - navigation.setParams({ requestedTrade: data }); + const sensitiveData = { + token_from_amount: fromTokenMinimalUnitString( + sourceAmount, + sourceToken.decimals, + ), + }; + navigation.setParams({ requestedTrade: { ...data, ...sensitiveData } }); navigation.setParams({ selectedQuote: undefined }); navigation.setParams({ quoteBegin: Date.now() }); trackEvent( createEventBuilder(MetaMetricsEvents.QUOTES_REQUESTED) - .addSensitiveProperties(data) + .addProperties(data) + .addSensitiveProperties(sensitiveData) .build(), ); }, [ diff --git a/app/components/UI/Swaps/index.js b/app/components/UI/Swaps/index.js index 5b1710f43c0..8d189c21d8f 100644 --- a/app/components/UI/Swaps/index.js +++ b/app/components/UI/Swaps/index.js @@ -275,7 +275,7 @@ function SwapsAmountView({ trackEvent( createEventBuilder(MetaMetricsEvents.SWAPS_OPENED) - .addSensitiveProperties(parameters) + .addProperties(parameters) .build(), ); }); From d49e8b6dcca843c2ea5a226aa7091a66f9a55ca9 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 11 Dec 2024 22:30:00 +0100 Subject: [PATCH 12/15] fix: fix native tokens filter when all networks is selected (#12637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** when the user has "all Networks" selected we want to filter out native tokens when the balance is zero and when hideZeroBalance setting is ON ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/4d395a89-4671-4e78-93d3-b3cf44f16d93 ### **After** https://github.com/user-attachments/assets/fd514b31-3c38-43a4-894d-5f929336b154 ## **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. --- app/components/UI/Tokens/index.test.tsx | 304 +++++++++++++++++++++--- app/components/UI/Tokens/index.tsx | 34 ++- 2 files changed, 298 insertions(+), 40 deletions(-) diff --git a/app/components/UI/Tokens/index.test.tsx b/app/components/UI/Tokens/index.test.tsx index 10058373636..13cbb2a6f8e 100644 --- a/app/components/UI/Tokens/index.test.tsx +++ b/app/components/UI/Tokens/index.test.tsx @@ -653,48 +653,282 @@ describe('Tokens', () => { expect(queryByText('MATIC')).toBeNull(); }); - it('should filter zero balance tokens when hideZeroBalanceTokens is enabled', () => { - const stateWithZeroBalances = { - ...initialState, - settings: { - hideZeroBalanceTokens: true, - }, - engine: { - backgroundState: { - ...initialState.engine.backgroundState, - TokensController: { - allTokens: { - '0x1': { - [selectedAddress]: [ - { - address: '0x123', - symbol: 'ZERO', - decimals: 18, - balance: '0', - balanceFiat: '$0', - isNative: false, - chainId: '0x1', + describe('When hideZeroBalance is enabled', () => { + describe('When currentNetwork is selected', () => { + it('should show zero balance native token and hide zero balance ERC20 token', () => { + const stateWithZeroBalances = { + ...initialState, + settings: { + hideZeroBalanceTokens: true, + }, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + PreferencesController: { + selectedAddress, + tokenSortConfig: { key: 'symbol', order: 'asc' }, + tokenNetworkFilter: { + '0x1': true, + }, + }, + TokenBalancesController: { + tokenBalances: { + [selectedAddress]: { + '0x1': { + '0x456': '1000000000000000000', + '0x5555': '0x0', + }, }, - { - address: '0x456', - symbol: 'NON_ZERO', - decimals: 18, - balance: '1000000000000000000', - balanceFiat: '$100', - isNative: false, - chainId: '0x1', + }, + }, + TokensController: { + allTokens: { + '0x1': { + [selectedAddress]: [ + { + address: '0x123', + symbol: 'ZERO', + decimals: 18, + balance: '0', + balanceFiat: '$0', + isNative: true, + chainId: '0x1', + }, + { + address: '0x456', + symbol: 'NON_ZERO_ERC20', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0x1', + }, + { + address: '0x5555', + symbol: 'ZERO_ERC20', + decimals: 18, + balance: '0', + balanceFiat: '0', + isNative: false, + chainId: '0x1', + }, + ], }, - ], + }, }, }, }, + }; + + const { queryByText } = renderComponent(stateWithZeroBalances); + expect(queryByText('ZERO')).toBeDefined(); + expect(queryByText('NON_ZERO_ERC20')).toBeDefined(); + expect(queryByText('ZERO_ERC20')).toBeNull(); + }); + }); + + describe('When allNetworks is selected', () => { + it('should hide zero balance ERC20 tokens and native tokens', () => { + const stateWithZeroBalances = { + ...initialState, + settings: { + hideZeroBalanceTokens: true, + }, + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + PreferencesController: { + selectedAddress, + tokenSortConfig: { key: 'symbol', order: 'asc' }, + tokenNetworkFilter: { + '0x1': true, + '0xe705': true, + }, + }, + TokenBalancesController: { + tokenBalances: { + [selectedAddress]: { + '0x1': { + NON_ZERO_ERC20_1: '1000000000000000000', + }, + '0xe705': { + '0x4565': '1000000000000000000', + '0x45654444': '0x0', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [selectedAddress]: [ + { + address: '0x123', + symbol: 'ZERO_1', + decimals: 18, + balance: '0', + balanceFiat: '$0', + isNative: true, + chainId: '0x1', + }, + { + address: '0x456', + symbol: 'NON_ZERO_ERC20_1', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0x1', + }, + ], + }, + '0xe705': { + [selectedAddress]: [ + { + address: '0x1233', + symbol: 'ZERO_2', + decimals: 18, + balance: '2233333', + balanceFiat: '$344', + isNative: true, + chainId: '0xe705', + }, + { + address: '0x4565', + symbol: 'NON_ZERO_ERC20_2', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0xe705', + }, + { + address: '0x45654444', + symbol: 'NON_ZERO_ERC20_3', + decimals: 18, + balance: '0', + balanceFiat: '0', + isNative: false, + chainId: '0xe705', + }, + ], + }, + }, + }, + }, + }, + }; + const { queryByText } = renderComponent(stateWithZeroBalances); + expect(queryByText('ZERO_1')).toBeNull(); + expect(queryByText('ZERO_2')).toBeDefined(); + + expect(queryByText('NON_ZERO_ERC20_1')).toBeDefined(); + expect(queryByText('NON_ZERO_ERC20_2')).toBeDefined(); + expect(queryByText('NON_ZERO_ERC20_3')).toBeNull(); + }); + }); + }); + + describe('When hideZeroBalance is disabled', () => { + it('should show zero balance native and ERC20 tokens', () => { + const stateWithZeroBalances = { + ...initialState, + settings: { + hideZeroBalanceTokens: false, }, - }, - }; + engine: { + backgroundState: { + ...initialState.engine.backgroundState, + PreferencesController: { + selectedAddress, + tokenSortConfig: { key: 'symbol', order: 'asc' }, + tokenNetworkFilter: { + '0x1': true, + '0xe705': true, + }, + }, + TokenBalancesController: { + tokenBalances: { + [selectedAddress]: { + '0x1': { + NON_ZERO_ERC20_1: '1000000000000000000', + }, + '0xe705': { + '0x4565': '1000000000000000000', + '0x45654444': '0x0', + }, + }, + }, + }, + TokensController: { + allTokens: { + '0x1': { + [selectedAddress]: [ + { + address: '0x123', + symbol: 'ZERO_1', + decimals: 18, + balance: '0', + balanceFiat: '$0', + isNative: true, + chainId: '0x1', + }, + { + address: '0x456', + symbol: 'NON_ZERO_ERC20_1', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0x1', + }, + ], + }, + '0xe705': { + [selectedAddress]: [ + { + address: '0x1233', + symbol: 'ZERO_2', + decimals: 18, + balance: '2233333', + balanceFiat: '$344', + isNative: true, + chainId: '0xe705', + }, + { + address: '0x4565', + symbol: 'NON_ZERO_ERC20_2', + decimals: 18, + balance: '1000000000000000000', + balanceFiat: '$100', + isNative: false, + chainId: '0xe705', + }, + { + address: '0x45654444', + symbol: 'NON_ZERO_ERC20_3', + decimals: 18, + balance: '0', + balanceFiat: '0', + isNative: false, + chainId: '0xe705', + }, + ], + }, + }, + }, + }, + }, + }; - const { queryByText } = renderComponent(stateWithZeroBalances); - expect(queryByText('ZERO')).toBeNull(); - expect(queryByText('NON_ZERO')).toBeDefined(); + const { queryByText } = renderComponent(stateWithZeroBalances); + expect(queryByText('ZERO_1')).toBeDefined(); + expect(queryByText('ZERO_2')).toBeDefined(); + + expect(queryByText('NON_ZERO_ERC20_1')).toBeDefined(); + expect(queryByText('NON_ZERO_ERC20_2')).toBeDefined(); + expect(queryByText('NON_ZERO_ERC20_3')).toBeDefined(); + }); }); }); }); diff --git a/app/components/UI/Tokens/index.tsx b/app/components/UI/Tokens/index.tsx index 11d8401521c..28cc7e10107 100644 --- a/app/components/UI/Tokens/index.tsx +++ b/app/components/UI/Tokens/index.tsx @@ -30,6 +30,7 @@ import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletV import { strings } from '../../../../locales/i18n'; import { IconName } from '../../../component-library/components/Icons/Icon'; import { + selectIsTokenNetworkFilterEqualCurrentNetwork, selectTokenNetworkFilter, selectTokenSortConfig, } from '../../../selectors/preferencesController'; @@ -104,6 +105,9 @@ const Tokens: React.FC = ({ tokens }) => { const hideZeroBalanceTokens = useSelector( (state: RootState) => state.settings.hideZeroBalanceTokens, ); + const isUserOnCurrentNetwork = useSelector( + selectIsTokenNetworkFilterEqualCurrentNetwork, + ); const tokenExchangeRates = useSelector(selectContractExchangeRates); const currentCurrency = useSelector(selectCurrentCurrency); @@ -144,17 +148,36 @@ const Tokens: React.FC = ({ tokens }) => { selectedAccountTokensChains, ).flat() as TokenI[]; - // First filter zero balance tokens if setting is enabled - const tokensToDisplay = hideZeroBalanceTokens - ? allTokens.filter((curToken) => { + /* + If hideZeroBalanceTokens is ON and user is on "all Networks" we respect the setting and filter native and ERC20 tokens when zero + If user is on "current Network" we want to show native tokens, even with zero balance + */ + let tokensToDisplay = []; + if (hideZeroBalanceTokens) { + if (isUserOnCurrentNetwork) { + tokensToDisplay = allTokens.filter((curToken) => { const multiChainTokenBalances = multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ curToken.chainId as Hex ]; const balance = multiChainTokenBalances?.[curToken.address as Hex]; return !isZero(balance) || curToken.isNative || curToken.isStaked; - }) - : allTokens; + }); + } else { + tokensToDisplay = allTokens.filter((curToken) => { + const multiChainTokenBalances = + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + curToken.chainId as Hex + ]; + const balance = + multiChainTokenBalances?.[curToken.address as Hex] || + curToken.balance; + return !isZero(balance) || curToken.isStaked; + }); + } + } else { + tokensToDisplay = allTokens; + } // Then apply network filters const filteredAssets = filterAssets(tokensToDisplay, [ @@ -273,6 +296,7 @@ const Tokens: React.FC = ({ tokens }) => { multiChainTokenBalance, networkConfigurationsByChainId, selectedInternalAccountAddress, + isUserOnCurrentNetwork, ]); const showRemoveMenu = (token: TokenI) => { From 963d2c4b749168ee49bc3df5f98687a378a6964a Mon Sep 17 00:00:00 2001 From: EtherWizard33 <165834542+EtherWizard33@users.noreply.github.com> Date: Wed, 11 Dec 2024 22:17:31 -0500 Subject: [PATCH 13/15] test(3615): additional e2e scenarios editing permissions and non permitted networks (#12597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Covers with e2e the scenarios: 1. should update chain permissions by granting and revoking network permissions simultaneously 2. should allow switching to permitted network when attempting to use non-permitted network ## **Related issues** Contributes to solve issue: https://github.com/MetaMask/MetaMask-planning/issues/2796 ## **Manual testing steps** I have added screenshot below to see how it looks. 1. `yarn watch:clean` 2. `yarn test:e2e:ios:debug:build` 3. `yarn test:e2e:ios:debug:run ` ## **Screenshots/Recordings** ![2024-12-03 12 50 35](https://github.com/user-attachments/assets/634d72f9-6518-446d-941f-9a9b0d99083b) ## **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. --- .../PermissionsSummary/PermissionsSummary.tsx | 3 + .../NetworkPermissionsConnected.tsx | 4 + .../NetworkConnectMultiSelector.tsx | 4 + .../Browser/NetworkConnectMultiSelector.js | 8 +- .../Browser/PermissionSummaryBottomSheet.js | 6 +- .../Network/NetworkNonPemittedBottomSheet.js | 31 +++++ .../NetworkConnectMultiSelector.selectors.js | 1 + ...NetworkNonPemittedBottomSheet.selectors.js | 4 + ...tem-connect-to-non-permitted-chain.spec.js | 112 ++++++++++++++++- ...ns-grant-one-account-and-one-chain.spec.js | 50 ++++++++ ...ssion-system-revoke-single-network.spec.js | 7 +- ...on-system-update-chain-permissions.spec.js | 113 ++++++++++++++++++ 12 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 e2e/specs/multichain/permission-system-update-chain-permissions.spec.js diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx index 2b6d27d344b..cf998ed53a2 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx @@ -505,6 +505,9 @@ const PermissionsSummary = ({