From bf126d02c6efa7e2f824b4005cc107682d1f0877 Mon Sep 17 00:00:00 2001 From: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:43:03 +0100 Subject: [PATCH 01/31] chore: bump {gas-fee,network,selected-network,notification-services,profile-sync,signature}-controller (#12577) 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/ppom-validator` from 0.35.1 to 0.36.0 ([view changes](https://github.com/MetaMask/ppom-validator/blob/main/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/gas-fee-controller` from 21.0.0 to 22.0.1 ([view changes](https://github.com/MetaMask/core/blob/main/packages/gas-fee-controller/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/signature-controller` from 22.0.0 to 23.1.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/signature-controller/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/profile-sync-controller` from 1.0.0 to 2.0.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/profile-sync-controller/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/selected-network-controller` from 18.0.2 to 19.0.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/selected-network-controller/CHANGELOG.md)) - There are no notable changes between these versions. - Bump `@metamask/network-controller` from 21.0.0 to 22.1.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/network-controller/CHANGELOG.md)) - Notable changes in 22.1.0: - The `NetworkController:networkRemoved` messenger event will now be emitted when a network is removed. - New messenger actions `NetworkController:addNetwork`, `NetworkController:removeNetwork`, and `NetworkController:updateNetwork` which call the respective controller methods. - New `lastUpdatedAt`property to network configurations which will be set to the current time on addition or update - Notable changes in 22.0.2: - `getDefaultNetworkConfigurationsByChainId` returns the updated display names for `mainnet` and `linea`. `Ethereum Mainnet` instead of `Mainnet`, and `Linea` instead of `Linea Mainnet`. - Bump `@metamask/notification-services-controller` from 0.11.0 to 0.14.0 ([view changes](https://github.com/MetaMask/core/blob/main/packages/notification-services-controller/CHANGELOG.md)) - Notable changes in 0.12.0: - `deleteNotificationsById` now publishes `NotificationServicesController:notificationsListUpdated` - Notable changes in 0.12.1: - When notifications are disabled, list is now repopulated with Snap notifications ## **Related issues** Completes https://github.com/MetaMask/MetaMask-planning/issues/3698 (and does more to satisfy peer dependency warnings). https://github.com/MetaMask/metamask-mobile/issues/12383 ## **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/lib/ppom/ppom-util.test.ts | 7 +- package.json | 14 +-- yarn.lock | 176 ++++++++++----------------------- 3 files changed, 65 insertions(+), 132 deletions(-) diff --git a/app/lib/ppom/ppom-util.test.ts b/app/lib/ppom/ppom-util.test.ts index 27340f43e23..89faa23a585 100644 --- a/app/lib/ppom/ppom-util.test.ts +++ b/app/lib/ppom/ppom-util.test.ts @@ -157,6 +157,7 @@ describe('PPOM Utils', () => { url: 'https://mainnet.infura.io/v3', }, ], + lastUpdatedAt: Date.now(), }, }, networksMetadata: {}, @@ -182,7 +183,9 @@ describe('PPOM Utils', () => { MockEngine.context.PreferencesController.state.securityAlertsEnabled = false; await PPOMUtil.validateRequest(mockRequest, CHAIN_ID_MOCK); - expect(MockEngine.context.PPOMController?.usePPOM).toHaveBeenCalledTimes(0); + expect(MockEngine.context.PPOMController?.usePPOM).toHaveBeenCalledTimes( + 0, + ); expect(spyTransactionAction).toHaveBeenCalledTimes(0); }); @@ -437,4 +440,4 @@ describe('PPOM Utils', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/package.json b/package.json index beed86b2ccf..c6435144c7f 100644 --- a/package.json +++ b/package.json @@ -168,21 +168,21 @@ "@metamask/ethjs-contract": "^0.4.1", "@metamask/ethjs-query": "^0.7.1", "@metamask/ethjs-unit": "^0.3.0", - "@metamask/gas-fee-controller": "^21.0.0", + "@metamask/gas-fee-controller": "^22.0.2", "@metamask/json-rpc-middleware-stream": "^8.0.2", "@metamask/key-tree": "^9.0.0", "@metamask/keyring-api": "^10.1.0", "@metamask/keyring-controller": "^19.0.1", "@metamask/logging-controller": "^6.0.1", "@metamask/message-signing-snap": "^0.3.3", - "@metamask/network-controller": "^21.0.0", - "@metamask/notification-services-controller": "^0.11.0", + "@metamask/network-controller": "^22.1.0", + "@metamask/notification-services-controller": "^0.14.0", "@metamask/permission-controller": "^11.0.0", "@metamask/phishing-controller": "^12.0.3", "@metamask/post-message-stream": "^8.0.0", - "@metamask/ppom-validator": "0.35.1", + "@metamask/ppom-validator": "0.36.0", "@metamask/preferences-controller": "^15.0.1", - "@metamask/profile-sync-controller": "^1.0.0", + "@metamask/profile-sync-controller": "^2.0.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", "@metamask/react-native-payments": "^2.0.0", @@ -192,8 +192,8 @@ "@metamask/rpc-errors": "^7.0.1", "@metamask/scure-bip39": "^2.1.0", "@metamask/sdk-communication-layer": "0.29.0-wallet", - "@metamask/selected-network-controller": "^18.0.2", - "@metamask/signature-controller": "^22.0.0", + "@metamask/selected-network-controller": "^19.0.0", + "@metamask/signature-controller": "^23.1.0", "@metamask/slip44": "^4.1.0", "@metamask/smart-transactions-controller": "^15.0.0", "@metamask/snaps-controllers": "^9.15.0", diff --git a/yarn.lock b/yarn.lock index 9b1a57a6cad..bb82ef73a6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4784,17 +4784,17 @@ is-hex-prefixed "1.0.0" strip-hex-prefix "1.0.0" -"@metamask/gas-fee-controller@^21.0.0": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@metamask/gas-fee-controller/-/gas-fee-controller-21.0.0.tgz#1cd21f3d33b097adb9c2d9bea17ac5ee72b491e6" - integrity sha512-34/AORJFpaHgr1jtzIk1/SzGSw7sYZL49kww7x7tW9wZcxV7r8rPZ5K25TveORzZtdfEsz1CVZjO4iEM8B4Lwg== +"@metamask/gas-fee-controller@^22.0.2": + version "22.0.2" + resolved "https://registry.yarnpkg.com/@metamask/gas-fee-controller/-/gas-fee-controller-22.0.2.tgz#6e092c3579d3b041ea252364a81190d8968cec5e" + integrity sha512-VaJHNWHL2mdswOIRrz5zes/recHRiXUG5sa5Co1eMFxpHQoX57ciFQ4J8rWsBP4P7H6a8MOB0TNFkn2fYCvpRg== dependencies: - "@metamask/base-controller" "^7.0.1" - "@metamask/controller-utils" "^11.3.0" + "@metamask/base-controller" "^7.0.2" + "@metamask/controller-utils" "^11.4.4" "@metamask/eth-query" "^4.0.0" "@metamask/ethjs-unit" "^0.3.0" - "@metamask/polling-controller" "^11.0.0" - "@metamask/utils" "^9.1.0" + "@metamask/polling-controller" "^12.0.2" + "@metamask/utils" "^10.0.0" "@types/bn.js" "^5.1.5" "@types/uuid" "^8.3.0" bn.js "^5.2.1" @@ -4891,38 +4891,7 @@ uuid "^9.0.1" webextension-polyfill "^0.12.0" -"@metamask/keyring-api@^8.1.3": - version "8.1.3" - resolved "https://registry.yarnpkg.com/@metamask/keyring-api/-/keyring-api-8.1.3.tgz#53e6a68236b88592db5bd43cf7e0d7e97dfad818" - integrity sha512-Ztm4G/U5hc+GKS/VOnqLWYVh2O26lFQ03bNpeufrfKq7regydIqYuHFcSowbQyj7xCZqKPsvl9jxhKdYIxvCXQ== - dependencies: - "@metamask/snaps-sdk" "^6.5.1" - "@metamask/superstruct" "^3.1.0" - "@metamask/utils" "^9.2.1" - "@types/uuid" "^9.0.8" - bech32 "^2.0.0" - uuid "^9.0.1" - -"@metamask/keyring-controller@^18.0.0": - version "18.0.0" - resolved "https://registry.yarnpkg.com/@metamask/keyring-controller/-/keyring-controller-18.0.0.tgz#48d5a441cd798cef840c5bf6334d53b6e37dc938" - integrity sha512-SIvjDUvt9U+fqge3ao0qZYfWrlBVf/NI/nFOwp9Q8IFpPsZYHKpbLahLfviunR/KItBul7vTzWBIN/FtQo/eLg== - dependencies: - "@ethereumjs/util" "^8.1.0" - "@keystonehq/metamask-airgapped-keyring" "^0.14.1" - "@metamask/base-controller" "^7.0.2" - "@metamask/browser-passworder" "^4.3.0" - "@metamask/eth-hd-keyring" "^7.0.4" - "@metamask/eth-sig-util" "^8.0.0" - "@metamask/eth-simple-keyring" "^6.0.5" - "@metamask/keyring-api" "^8.1.3" - "@metamask/message-manager" "^11.0.1" - "@metamask/utils" "^10.0.0" - async-mutex "^0.5.0" - ethereumjs-wallet "^1.0.1" - immer "^9.0.6" - -"@metamask/keyring-controller@^19.0.1": +"@metamask/keyring-controller@^19.0.0", "@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== @@ -4950,7 +4919,7 @@ "@metamask/controller-utils" "^11.3.0" uuid "^8.3.2" -"@metamask/message-manager@^11.0.1", "@metamask/message-manager@^11.0.2": +"@metamask/message-manager@^11.0.2": version "11.0.2" resolved "https://registry.yarnpkg.com/@metamask/message-manager/-/message-manager-11.0.2.tgz#805ac8d2184c56d973a787bdc7c9571024348ce6" integrity sha512-j8DgWn7ACfwZtLFqeAYx4U/TquBEtcPYa81LjhoZYK39W/MQVr4ZKAmGEKxKRruMLz50GX7ciOEZU9SBtPBAog== @@ -4986,36 +4955,13 @@ resolved "https://registry.yarnpkg.com/@metamask/mobile-provider/-/mobile-provider-3.0.0.tgz#8a6a5a0874c8cbe4b468f63dfc57117d207f9595" integrity sha512-XwFJk0rd9lAZR5xS3VC7ypEhD7DvZR2gi2Ch6PHnODIqeS9Te3OdVKK5+jHI4his8v/zs6LWdFdlRtx5/jL96w== -"@metamask/network-controller@^21.0.0": - version "21.1.0" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-21.1.0.tgz#766598a7e854a86bcb5f94af6c7bbc7f67820d02" - integrity sha512-AyGumRyUtuQAkYJ/7f/KSPqwc+vndUP20BE0NbGo2hVfMS/IOdvUj2Dz+c8+LnqkO6JfyuQ7BVJiMy5JnMucJw== - dependencies: - "@metamask/base-controller" "^7.0.1" - "@metamask/controller-utils" "^11.3.0" - "@metamask/eth-block-tracker" "^11.0.2" - "@metamask/eth-json-rpc-infura" "^10.0.0" - "@metamask/eth-json-rpc-middleware" "^15.0.0" - "@metamask/eth-json-rpc-provider" "^4.1.5" - "@metamask/eth-query" "^4.0.0" - "@metamask/json-rpc-engine" "^10.0.0" - "@metamask/rpc-errors" "^7.0.0" - "@metamask/swappable-obj-proxy" "^2.2.0" - "@metamask/utils" "^9.1.0" - async-mutex "^0.5.0" - immer "^9.0.6" - loglevel "^1.8.1" - reselect "^5.1.1" - uri-js "^4.4.1" - uuid "^8.3.2" - -"@metamask/network-controller@^22.0.2": - version "22.0.2" - resolved "https://registry.yarnpkg.com/@metamask/network-controller/-/network-controller-22.0.2.tgz#d6f5464d26bc9b68731cc5c7ec32845f63d1fcf0" - integrity sha512-DfcDdmtVRp7TrtbvaSFX+98jE/KSorhTy/znWOpe1zt60q6v3+SVttT2LJlZpvpk0hyiDPNBO4lmk3AOovR2/w== +"@metamask/network-controller@^22.0.2", "@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== dependencies: "@metamask/base-controller" "^7.0.2" - "@metamask/controller-utils" "^11.4.3" + "@metamask/controller-utils" "^11.4.4" "@metamask/eth-block-tracker" "^11.0.2" "@metamask/eth-json-rpc-infura" "^10.0.0" "@metamask/eth-json-rpc-middleware" "^15.0.0" @@ -5026,6 +4972,7 @@ "@metamask/swappable-obj-proxy" "^2.2.0" "@metamask/utils" "^10.0.0" async-mutex "^0.5.0" + fast-deep-equal "^3.1.3" immer "^9.0.6" loglevel "^1.8.1" reselect "^5.1.1" @@ -5040,16 +4987,16 @@ "@ethersproject/providers" "^5.7.2" async-mutex "^0.3.1" -"@metamask/notification-services-controller@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@metamask/notification-services-controller/-/notification-services-controller-0.11.0.tgz#6f1f8553e73bbaa5e3631c772efc34640264ff81" - integrity sha512-2QplJt1gqdPkhnHN6+C1E8MrcdZh1JgoDf9YnL2WsuCmMFlpujYD7mW7lRInBnCrPbKliHgF96oOT0D5gFigoA== +"@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== dependencies: "@contentful/rich-text-html-renderer" "^16.5.2" - "@metamask/base-controller" "^7.0.1" - "@metamask/controller-utils" "^11.3.0" - "@metamask/utils" "^9.1.0" - bignumber.js "^4.1.0" + "@metamask/base-controller" "^7.0.2" + "@metamask/controller-utils" "^11.4.3" + "@metamask/utils" "^10.0.0" + bignumber.js "^9.1.2" firebase "^10.11.0" loglevel "^1.8.1" uuid "^8.3.2" @@ -5124,18 +5071,6 @@ fastest-levenshtein "^1.0.16" punycode "^2.1.1" -"@metamask/polling-controller@^11.0.0": - version "11.0.0" - resolved "https://registry.yarnpkg.com/@metamask/polling-controller/-/polling-controller-11.0.0.tgz#3de28c27a9be5d85879cf9bd35d03a62dbf8932c" - integrity sha512-fXG60Ft7lYgqC8lV8m4FL18hU4K2NnsPzoWTm1Lhen2cysLbgAzqxxGdeIFEMaDiCzcvEtLuEffY8FDKCM0ewQ== - dependencies: - "@metamask/base-controller" "^7.0.1" - "@metamask/controller-utils" "^11.3.0" - "@metamask/utils" "^9.1.0" - "@types/uuid" "^8.3.0" - fast-json-stable-stringify "^2.1.0" - uuid "^8.3.2" - "@metamask/polling-controller@^12.0.0", "@metamask/polling-controller@^12.0.2": version "12.0.2" resolved "https://registry.yarnpkg.com/@metamask/polling-controller/-/polling-controller-12.0.2.tgz#de37ff5f4f997d57d4da34b0c6574d2eba3184e0" @@ -5156,10 +5091,10 @@ "@metamask/utils" "^9.0.0" readable-stream "3.6.2" -"@metamask/ppom-validator@0.35.1": - version "0.35.1" - resolved "https://registry.yarnpkg.com/@metamask/ppom-validator/-/ppom-validator-0.35.1.tgz#9ccece28f9f8947caef575849ef41f2ca15baa13" - integrity sha512-iaJRIFUXNBeuQwy+6B10mkPJDAmhvSyu5PAehSNSj+J2Q7Vy3AJe9GWI5t62j3IeDnwuny+Av+x/T+iI5D/b8w== +"@metamask/ppom-validator@0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@metamask/ppom-validator/-/ppom-validator-0.36.0.tgz#cc8ace84ead3521c1b079650fa4169d1020bc070" + integrity sha512-9PN+QZpQCq0ctu0b7LeHFWeZQ5phKavVQ7t0tp2ZYtea6ql7zayjDhZQitoFUEHN3R6BuHxn5ORfDGFJgNDL9Q== dependencies: "@metamask/base-controller" "^7.0.1" "@metamask/controller-utils" "^11.3.0" @@ -5178,17 +5113,17 @@ "@metamask/base-controller" "^7.0.2" "@metamask/controller-utils" "^11.4.4" -"@metamask/profile-sync-controller@^1.0.0": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@metamask/profile-sync-controller/-/profile-sync-controller-1.0.2.tgz#877a04cdcda2441bd30ac996581f520b4b4f3c53" - integrity sha512-mFZtnmMR040QxnZx8U7hs/UVToBZxPxJCB+QWnUkkSUZ/rk2Yq1nWepxJEgxxrdyb4goHGzFGFUId+Fr0ezBjg== +"@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== dependencies: "@metamask/base-controller" "^7.0.2" - "@metamask/keyring-api" "^8.1.3" - "@metamask/keyring-controller" "^18.0.0" + "@metamask/keyring-api" "^10.1.0" + "@metamask/keyring-controller" "^19.0.0" "@metamask/network-controller" "^22.0.2" - "@metamask/snaps-sdk" "^6.5.0" - "@metamask/snaps-utils" "^8.1.1" + "@metamask/snaps-sdk" "^6.7.0" + "@metamask/snaps-utils" "^8.3.0" "@noble/ciphers" "^0.5.2" "@noble/hashes" "^1.4.0" immer "^9.0.6" @@ -5310,26 +5245,26 @@ utf-8-validate "^5.0.2" uuid "^8.3.2" -"@metamask/selected-network-controller@^18.0.2": - version "18.0.2" - resolved "https://registry.yarnpkg.com/@metamask/selected-network-controller/-/selected-network-controller-18.0.2.tgz#a6bd7916c47307999cada50d8e5d3d839a29246c" - integrity sha512-0a0uAW3EH56zWuDtCw5al6PcwWjDEsy4ydoUV77+Ko8h46WsS8gbV4VpoKAN+MJpihty0LVM8J6whT3Z5qP7iQ== +"@metamask/selected-network-controller@^19.0.0": + version "19.0.0" + resolved "https://registry.yarnpkg.com/@metamask/selected-network-controller/-/selected-network-controller-19.0.0.tgz#7c950e051d6848ac5cdcf980f90196a0772425ef" + integrity sha512-ijCFHwZN+73VskJU+X0AoSlCTycgLveEjwUoycUeajmUuZyLV5KLLVYPKTk0Pd/aQAaO2tL9yZi0/geazNGV7A== dependencies: "@metamask/base-controller" "^7.0.1" "@metamask/json-rpc-engine" "^10.0.0" "@metamask/swappable-obj-proxy" "^2.2.0" - "@metamask/utils" "^9.1.0" + "@metamask/utils" "^10.0.0" -"@metamask/signature-controller@^22.0.0": - version "22.0.0" - resolved "https://registry.yarnpkg.com/@metamask/signature-controller/-/signature-controller-22.0.0.tgz#a52c79881aae4b47fecfc2c2b1f2d0e1e649e240" - integrity sha512-k4Kvq4tdFDWLiFiNijUeOGVQj10PfNp5R6DCPXwMgQnJqmUBXM14i/kMN4lU7rRsieYhQdXRVOQZ3/5r3wCstg== +"@metamask/signature-controller@^23.1.0": + version "23.1.0" + resolved "https://registry.yarnpkg.com/@metamask/signature-controller/-/signature-controller-23.1.0.tgz#45b3b545e5a4e890ff41a737b6526cb08fd9f1b3" + integrity sha512-HPUDjVjsZ/HU5QZlmllh1yN2Z1+VhqjTPmzxqXBqD28iKYwUU0YEMN+Jahyh9Ukl7BH/UgzGaaHytGXJ1Xf2Xw== dependencies: "@metamask/base-controller" "^7.0.2" - "@metamask/controller-utils" "^11.4.3" + "@metamask/controller-utils" "^11.4.4" "@metamask/eth-sig-util" "^8.0.0" "@metamask/utils" "^10.0.0" - jsonschema "^1.2.4" + jsonschema "^1.4.1" lodash "^4.17.21" uuid "^8.3.2" @@ -5442,7 +5377,7 @@ fast-xml-parser "^4.3.4" superstruct "^1.0.3" -"@metamask/snaps-sdk@^6.11.0", "@metamask/snaps-sdk@^6.13.0", "@metamask/snaps-sdk@^6.5.0", "@metamask/snaps-sdk@^6.5.1", "@metamask/snaps-sdk@^6.7.0": +"@metamask/snaps-sdk@^6.11.0", "@metamask/snaps-sdk@^6.13.0", "@metamask/snaps-sdk@^6.7.0": version "6.13.0" resolved "https://registry.yarnpkg.com/@metamask/snaps-sdk/-/snaps-sdk-6.13.0.tgz#cbfef71253264efd4e0ef4606f89e93f83885816" integrity sha512-WXNt0XZSnmgbwgETL0RiRvl0CMa78ZA1zLS0olK8QR/+9zcPCSrh68v1lVAa+LcctvzpRJ8NiRAar2fRBthqyw== @@ -5453,7 +5388,7 @@ "@metamask/superstruct" "^3.1.0" "@metamask/utils" "^10.0.0" -"@metamask/snaps-utils@^8.1.1", "@metamask/snaps-utils@^8.3.0", "@metamask/snaps-utils@^8.6.0", "@metamask/snaps-utils@^8.6.1": +"@metamask/snaps-utils@^8.3.0", "@metamask/snaps-utils@^8.6.0", "@metamask/snaps-utils@^8.6.1": version "8.6.1" resolved "https://registry.yarnpkg.com/@metamask/snaps-utils/-/snaps-utils-8.6.1.tgz#9f3b52f9b00a93ce9e3f22e79066fea0d6df458a" integrity sha512-R6Gj6Im5gV09kjkSH9vDKrvsqNGaFGbSAl95fhlQBW3QevLaMbvAF4bCfhA3YCfsEYZUWsgplYpCjX78q2jvBA== @@ -12772,11 +12707,6 @@ big-integer@1.6.x: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== -bignumber.js@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-4.1.0.tgz#db6f14067c140bd46624815a7916c92d9b6c24b1" - integrity sha512-eJzYkFYy9L4JzXsbymsFn3p54D+llV27oTQ+ziJG7WFRheJcNZilgVXMG0LoZtlQSKBsJdWtLFqOD0u+U0jZKA== - bignumber.js@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-7.2.1.tgz#80c048759d826800807c4bfd521e50edbba57a5f" @@ -20666,10 +20596,10 @@ jsonpath-plus@^7.2.0: resolved "https://registry.yarnpkg.com/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz#7ad94e147b3ed42f7939c315d2b9ce490c5a3899" integrity sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA== -jsonschema@^1.2.4: - version "1.4.0" - resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2" - integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw== +jsonschema@^1.2.4, jsonschema@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab" + integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ== jsprim@^1.2.2: version "1.4.1" From 2adeecd2b5236abd3d306551521449e1796297c7 Mon Sep 17 00:00:00 2001 From: Jongsun Suh Date: Mon, 9 Dec 2024 12:17:47 -0500 Subject: [PATCH 02/31] chore(deps): Bump `@metamask/composable-controller` from `^3.0.0` to `^10.0.0` (#10441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This commit updates `@metamask/composable-controller` to `^10.0.0`. This involves fixing bugs outlined in https://github.com/MetaMask/metamask-mobile/issues/10073, and applying the major changes to the `ComposableController` API that has accumulated between the intervening versions. ## Blocked by - https://github.com/MetaMask/core/pull/4968 - `composable-controller` v10 has been written to ensure that the effect of this optimization is maximized. - https://github.com/MetaMask/metamask-mobile/pull/12348 - https://github.com/MetaMask/metamask-mobile/pull/12407 - ~https://github.com/MetaMask/metamask-mobile/pull/11162~ ## Changelog ### Changed - **BREAKING:** Bump `@metamask/composable-controller` from `^3.0.0` to `^10.0.0`. - **BREAKING:** Instantiate `ComposableController` class constructor option `messenger` with a `RestrictedControllerMessenger` instance derived from the `controllerMessenger` class field instance of `Engine`, instead of passing in the unrestricted `controllerMessenger` instance directly. - **BREAKING:** Narrow external actions allowlist for `messenger` instance passed into `ComposableController` constructor from `GlobalActions['type']` to an empty union. - **BREAKING:** Narrow external events allowlist for `messenger` instance passed into `ComposableController` constructor from `GlobalEvents['type']` to a union of the `stateChange` events of all controllers included in the `EngineState` type. - Convert the `EngineState` interface to use type alias syntax to ensure compatibility with types used in MetaMask controllers. ### Fixed - **BREAKING:** Narrow `Engine` class `datamodel` field from `any` to `ComposableController`. - **BREAKING:** The `CurrencyRatesController` constructor now normalizes `null` into 0 for the `conversionRate` values of all native currencies keyed in the `currencyRates` object of `CurrencyRatesControllerState`. - Restore previously suppressed type-level validation for `ComposableController` constructor option `controllers`. ## **Related issues** - Closes https://github.com/MetaMask/metamask-mobile/issues/10073 - Applies https://github.com/MetaMask/core/pull/4467/ - Blocked by `@metamask/composable-controller@8.0.0` release. - Supersedes https://github.com/MetaMask/metamask-mobile/pull/10011 ## **Manual testing steps** ## **Screenshots/Recordings** ## **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. --------- Co-authored-by: Salah-Eddine Saakoun --- app/core/BackgroundBridge/BackgroundBridge.js | 2 +- .../BackgroundBridge/WalletConnectPort.ts | 4 +- app/core/Engine/Engine.test.ts | 33 +++++++-- app/core/Engine/Engine.ts | 73 ++++++++++--------- app/core/Engine/constants.ts | 45 ++++++++++++ app/core/Engine/types.ts | 37 ++++++++-- app/core/EngineService/EngineService.test.ts | 5 +- app/core/EngineService/EngineService.ts | 24 +++--- app/util/test/initial-background-state.json | 3 - package.json | 2 +- yarn.lock | 20 ++--- 11 files changed, 166 insertions(+), 82 deletions(-) create mode 100644 app/core/Engine/constants.ts diff --git a/app/core/BackgroundBridge/BackgroundBridge.js b/app/core/BackgroundBridge/BackgroundBridge.js index 0305cfb53fc..73ac7a3efd5 100644 --- a/app/core/BackgroundBridge/BackgroundBridge.js +++ b/app/core/BackgroundBridge/BackgroundBridge.js @@ -497,7 +497,7 @@ export class BackgroundBridge extends EventEmitter { */ getState() { const vault = Engine.context.KeyringController.state.vault; - const { selectedAddress } = Engine.datamodel.flatState; + const { PreferencesController: { selectedAddress } } = Engine.datamodel.state; return { isInitialized: !!vault, isUnlocked: true, diff --git a/app/core/BackgroundBridge/WalletConnectPort.ts b/app/core/BackgroundBridge/WalletConnectPort.ts index f19442ec01b..9acd8a76d48 100644 --- a/app/core/BackgroundBridge/WalletConnectPort.ts +++ b/app/core/BackgroundBridge/WalletConnectPort.ts @@ -23,7 +23,9 @@ class WalletConnectPort extends EventEmitter { if (msg?.data?.method === NOTIFICATION_NAMES.chainChanged) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const { selectedAddress } = Engine.datamodel.flatState; + const { + PreferencesController: { selectedAddress }, + } = Engine.datamodel.state; this._wcRequestActions?.updateSession?.({ chainId: parseInt(msg.data.params.chainId, 16), accounts: [selectedAddress], diff --git a/app/core/Engine/Engine.test.ts b/app/core/Engine/Engine.test.ts index e8dc933f51c..5af5d8965c8 100644 --- a/app/core/Engine/Engine.test.ts +++ b/app/core/Engine/Engine.test.ts @@ -70,14 +70,7 @@ describe('Engine', () => { it('matches initial state fixture', () => { const engine = Engine.init({}); const initialBackgroundState = engine.datamodel.state; - - // AssetsContractController is stateless in v37 resulting in an undefined state - const newBackgroundState = { - ...backgroundState, - AssetsContractController: undefined, - }; - - expect(initialBackgroundState).toStrictEqual(newBackgroundState); + expect(initialBackgroundState).toStrictEqual(backgroundState); }); it('setSelectedAccount throws an error if no account exists for the given address', () => { @@ -89,6 +82,30 @@ describe('Engine', () => { ); }); + it('normalizes CurrencyController state property conversionRate from null to 0', () => { + const ticker = 'ETH'; + const state = { + CurrencyRateController: { + currentCurrency: 'usd' as const, + currencyRates: { + [ticker]: { + conversionRate: null, + conversionDate: 0, + usdConversionRate: null, + }, + }, + }, + }; + const engine = Engine.init(state); + expect( + engine.datamodel.state.CurrencyRateController.currencyRates[ticker], + ).toStrictEqual({ + conversionRate: 0, + conversionDate: 0, + usdConversionRate: null, + }); + }); + describe('getTotalFiatAccountBalance', () => { let engine: EngineClass; afterEach(() => engine?.destroyEngineInstance()); diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 44f8fea10a9..294baf2b96f 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -200,11 +200,15 @@ import { JsonMap } from '../Analytics/MetaMetrics.types'; import { isPooledStakingFeatureEnabled } from '../../components/UI/Stake/constants'; import { ControllerMessenger, - Controllers, EngineState, EngineContext, TransactionEventPayload, + StatefulControllers, } from './types'; +import { + BACKGROUND_STATE_CHANGE_EVENT_NAMES, + STATELESS_NON_CONTROLLER_NAMES, +} from './constants'; import { getGlobalChainId, getGlobalNetworkClientId, @@ -239,9 +243,7 @@ export class Engine { /** * ComposableController reference containing all child controllers */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - datamodel: any; + datamodel: ComposableController; /** * Object containing the info for the latest incoming tx block @@ -449,7 +451,25 @@ export class Engine { allowedActions: [`${networkController.name}:getNetworkClientById`], allowedEvents: [], }), - state: initialState.CurrencyRateController, + // normalize `null` currencyRate to `0` + // TODO: handle `null` currencyRate by hiding fiat values instead + state: { + ...initialState.CurrencyRateController, + currencyRates: Object.fromEntries( + Object.entries( + initialState.CurrencyRateController?.currencyRates ?? { + ETH: { + conversionRate: 0, + conversionDate: 0, + usdConversionRate: null, + }, + }, + ).map(([k, v]) => [ + k, + { ...v, conversionRate: v.conversionRate ?? 0 }, + ]), + ), + }, }); const gasFeeController = new GasFeeController({ @@ -1457,24 +1477,21 @@ export class Engine { }), }; - // Avoiding `Object.values` and `getKnownPropertyNames` for performance benefits: https://www.measurethat.net/Benchmarks/Show/7173/0/objectvalues-vs-reduce - const controllers = ( - Object.keys(this.context) as (keyof Controllers)[] - ).reduce( - (controllers, controllerName) => { - const controller = this.context[controllerName]; - if (controller) { - controllers.push(controller); - } - return controllers; + const childControllers = Object.assign({}, this.context); + STATELESS_NON_CONTROLLER_NAMES.forEach((name) => { + if (name in childControllers && childControllers[name]) { + delete childControllers[name]; + } + }); + this.datamodel = new ComposableController( + { + controllers: childControllers as StatefulControllers, + messenger: this.controllerMessenger.getRestricted({ + name: 'ComposableController', + allowedActions: [], + allowedEvents: Array.from(BACKGROUND_STATE_CHANGE_EVENT_NAMES), + }), }, - [], - ); - - this.datamodel = new ComposableController( - // @ts-expect-error TODO: Filter out non-controller instances - controllers, - this.controllerMessenger, ); const { NftController: nfts } = this.context; @@ -2074,22 +2091,12 @@ export default { AccountsController, } = instance.datamodel.state; - // normalize `null` currencyRate to `0` - // TODO: handle `null` currencyRate by hiding fiat values instead - const modifiedCurrencyRateControllerState = { - ...CurrencyRateController, - conversionRate: - CurrencyRateController.conversionRate === null - ? 0 - : CurrencyRateController.conversionRate, - }; - return { AccountTrackerController, AddressBookController, NftController, TokenListController, - CurrencyRateController: modifiedCurrencyRateControllerState, + CurrencyRateController, KeyringController, NetworkController, PhishingController, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts new file mode 100644 index 00000000000..cc33feb0f69 --- /dev/null +++ b/app/core/Engine/constants.ts @@ -0,0 +1,45 @@ +/** + * Messageable modules that are part of the Engine's context, but are not defined with state. + * TODO: Replace with type guard once consistent inheritance for non-controllers is implemented. See: https://github.com/MetaMask/decisions/pull/41 + */ +export const STATELESS_NON_CONTROLLER_NAMES = [ + 'AssetsContractController', + 'NftDetectionController', + 'TokenDetectionController', +] as const; + +export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ + 'AccountsController:stateChange', + 'AccountTrackerController:stateChange', + 'AddressBookController:stateChange', + 'ApprovalController:stateChange', + 'CurrencyRateController:stateChange', + 'GasFeeController:stateChange', + 'KeyringController:stateChange', + 'LoggingController:stateChange', + 'NetworkController:stateChange', + 'NftController:stateChange', + 'PermissionController:stateChange', + 'PhishingController:stateChange', + 'PPOMController:stateChange', + 'PreferencesController:stateChange', + 'RemoteFeatureFlagController:stateChange', + 'SelectedNetworkController:stateChange', + 'SignatureController:stateChange', + 'SmartTransactionsController:stateChange', + 'SwapsController:stateChange', + 'TokenBalancesController:stateChange', + 'TokenListController:stateChange', + 'TokenRatesController:stateChange', + 'TokensController:stateChange', + 'TransactionController:stateChange', + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) + 'SnapController:stateChange', + 'SnapsRegistry:stateChange', + 'SubjectMetadataController:stateChange', + 'AuthenticationController:stateChange', + 'UserStorageController:stateChange', + 'NotificationServicesController:stateChange', + 'NotificationServicesPushController:stateChange', + ///: END:ONLY_INCLUDE_IF +] as const; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index d9bc8028859..7484b425db7 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -159,10 +159,16 @@ import { AccountsControllerState, } from '@metamask/accounts-controller'; import { getPermissionSpecifications } from '../Permissions/specifications.js'; +import { ComposableControllerEvents } from '@metamask/composable-controller'; +import { STATELESS_NON_CONTROLLER_NAMES } from './constants'; import { RemoteFeatureFlagController, - RemoteFeatureFlagControllerState + RemoteFeatureFlagControllerState, } from '@metamask/remote-feature-flag-controller'; +import { + RemoteFeatureFlagControllerActions, + RemoteFeatureFlagControllerEvents, +} from '@metamask/remote-feature-flag-controller/dist/remote-feature-flag-controller.cjs'; /** * Controllers that area always instantiated @@ -174,6 +180,14 @@ type RequiredControllers = Omit; */ type OptionalControllers = Pick; +/** + * Controllers that are defined with state. + */ +export type StatefulControllers = Omit< + Controllers, + (typeof STATELESS_NON_CONTROLLER_NAMES)[number] +>; + type PermissionsByRpcMethod = ReturnType; type Permissions = PermissionsByRpcMethod[keyof PermissionsByRpcMethod]; @@ -223,9 +237,11 @@ type GlobalActions = | TransactionControllerActions | SelectedNetworkControllerActions | SmartTransactionsControllerActions - | AssetsContractControllerActions; + | AssetsContractControllerActions + | RemoteFeatureFlagControllerActions; type GlobalEvents = + | ComposableControllerEvents | AccountTrackerControllerEvents | NftControllerEvents | SwapsControllerEvents @@ -255,7 +271,8 @@ type GlobalEvents = | TransactionControllerEvents | SelectedNetworkControllerEvents | SmartTransactionsControllerEvents - | AssetsContractControllerEvents; + | AssetsContractControllerEvents + | RemoteFeatureFlagControllerEvents; // TODO: Abstract this into controller utils for TransactionController export interface TransactionEventPayload { @@ -276,7 +293,10 @@ export type ControllerMessenger = ExtendedControllerMessenger< /** * All mobile controllers, keyed by name */ -export interface Controllers { +// Interfaces are incompatible with our controllers and state types by default. +// Adding an index signature fixes this, but at the cost of widening the type unnecessarily. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type Controllers = { AccountsController: AccountsController; AccountTrackerController: AccountTrackerController; AddressBookController: AddressBookController; @@ -316,7 +336,7 @@ export interface Controllers { NotificationServicesPushController: NotificationServicesPushController.Controller; ///: END:ONLY_INCLUDE_IF SwapsController: SwapsController; -} +}; /** * Combines required and optional controllers for the Engine context type. @@ -326,7 +346,10 @@ export type EngineContext = RequiredControllers & Partial; /** * All engine state, keyed by controller name */ -export interface EngineState { +// Interfaces are incompatible with our controllers and state types by default. +// Adding an index signature fixes this, but at the cost of widening the type unnecessarily. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type EngineState = { AccountTrackerController: AccountTrackerControllerState; AddressBookController: AddressBookControllerState; NftController: NftControllerState; @@ -359,4 +382,4 @@ export interface EngineState { PPOMController: PPOMState; AccountsController: AccountsControllerState; SelectedNetworkController: SelectedNetworkControllerState; -} +}; diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 2d5d0fcacf4..20ee32ab5bf 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -35,7 +35,10 @@ jest.mock('../Engine', () => { destroyEngine: jest.fn(), init: jest.fn((_, keyringState) => { instance = { - controllerMessenger: { subscribe: jest.fn() }, + controllerMessenger: { + subscribe: jest.fn(), + subscribeOnceIf: jest.fn(), + }, context: { AddressBookController: { subscribe: jest.fn() }, KeyringController: { diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index 293502f18dc..bedb3e97534 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -173,29 +173,27 @@ class EngineService { }, ]; - engine?.datamodel?.subscribe?.(() => { - if (!engine.context.KeyringController.metadata.vault) { - Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); - } - if (!this.engineInitialized) { + engine.controllerMessenger.subscribeOnceIf( + 'ComposableController:stateChange', + () => { + if (!engine.context.KeyringController.metadata.vault) { + Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); + } store.dispatch({ type: INIT_BG_STATE_KEY }); this.engineInitialized = true; - } - }); + }, + () => !this.engineInitialized, + ); controllers.forEach((controller) => { - const { name, key = undefined } = controller; + const { name, key } = controller; const update_bg_state_cb = () => { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for UPDATE_BG_STATE_KEY'); } store.dispatch({ type: UPDATE_BG_STATE_KEY, payload: { key: name } }); }; - if (key) { - engine.controllerMessenger.subscribe(key, update_bg_state_cb); - } else { - engine.context[name].subscribe(update_bg_state_cb); - } + engine.controllerMessenger.subscribe(key, update_bg_state_cb); }); }; diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 65b0315afd7..0191af34f02 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -21,7 +21,6 @@ "AddressBookController": { "addressBook": {} }, - "AssetsContractController": {}, "NftController": { "allNftContracts": {}, "allNfts": {}, @@ -40,8 +39,6 @@ "tokensChainsCache": {}, "preventPollingOnNetworkRestart": false }, - "TokenDetectionController": {}, - "NftDetectionController": {}, "PPOMController": { "storageMetadata": [], "versionInfo": [] diff --git a/package.json b/package.json index c6435144c7f..f27eefc20d4 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,7 @@ "@metamask/assets-controllers": "^45.1.1", "@metamask/base-controller": "^7.0.1", "@metamask/bitcoin-wallet-snap": "^0.8.2", - "@metamask/composable-controller": "^3.0.0", + "@metamask/composable-controller": "^10.0.0", "@metamask/controller-utils": "^11.3.0", "@metamask/design-tokens": "^4.0.0", "@metamask/eth-hd-keyring": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index bb82ef73a6d..aa0795670d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4443,14 +4443,6 @@ single-call-balance-checker-abi "^1.0.0" uuid "^8.3.2" -"@metamask/base-controller@^3.2.3": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@metamask/base-controller/-/base-controller-3.2.3.tgz#7436a14f6789acf0814952dabaa70ee4fb7d473c" - integrity sha512-k66oZe7BOEx0D5N5X8feE/32QlrUTmiEHHAZU/yCac2+VHllJOCEQV/cTeaAtgepnEf8O7SskvYZN+eIjgS99w== - dependencies: - "@metamask/utils" "^8.1.0" - immer "^9.0.6" - "@metamask/base-controller@^7.0.1", "@metamask/base-controller@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@metamask/base-controller/-/base-controller-7.0.2.tgz#bf908858215cd4f7d072b3b0f7f0946cf886ee49" @@ -4486,12 +4478,12 @@ "@metamask/utils" "^8.2.0" "@types/eslint" "^8.44.7" -"@metamask/composable-controller@^3.0.0": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@metamask/composable-controller/-/composable-controller-3.0.3.tgz#9644a65c482996ee033eae34e169eb9713e4a33e" - integrity sha512-dF8emJGwJi66Uhk+VoBaQpl5+DT3+cT/g/IBZ6pg4cbAu8L/kXuS3sw8JEKFskkaSVy/can8qgcBY5zU4LfeUA== +"@metamask/composable-controller@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@metamask/composable-controller/-/composable-controller-10.0.0.tgz#f2b9d1e6abc6c21f93f51ccf3c7408c3b7cb508f" + integrity sha512-0cShYc2CSBUKbzyI6pzQUyjG9vnVjgpv7Ydnba/uzUkOaylVxN7uyruSz2nO22TIWVsKQsH8mS+LIYC1fOJjsQ== dependencies: - "@metamask/base-controller" "^3.2.3" + "@metamask/base-controller" "^7.0.2" "@metamask/contract-metadata@^2.4.0": version "2.5.0" @@ -5514,7 +5506,7 @@ semver "^7.3.8" superstruct "^1.0.3" -"@metamask/utils@^8.1.0", "@metamask/utils@^8.2.0", "@metamask/utils@^8.3.0": +"@metamask/utils@^8.2.0", "@metamask/utils@^8.3.0": version "8.5.0" resolved "https://registry.yarnpkg.com/@metamask/utils/-/utils-8.5.0.tgz#ddd0d4012d5191809404c97648a837ea9962cceb" integrity sha512-I6bkduevXb72TIM9q2LRO63JSsF9EXduh3sBr9oybNX2hNNpr/j1tEjXrsG0Uabm4MJ1xkGAQEMwifvKZIkyxQ== From d2ad6fd624e16e8200851a3e6e7705285f0ba67a Mon Sep 17 00:00:00 2001 From: Andre Pimenta Date: Mon, 9 Dec 2024 20:20:42 +0000 Subject: [PATCH 03/31] docs: Update README.md with new expo instructions (#12617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adding new instructions for Expo: #### For internal developers - Access Runway via Okta and go to the Expo bucket either on the iOS or Android section. From there you will see the available development builds (android-expo-dev-build.apk or ios-expo-dev-build.ipa). - For Android: - Install the .apk on your Android device or simulator. - For iOS: - Device: you need to have your iPhone registered with our Apple dev account. If you have it, you can install the .ipa on your device. - Simulator: please follow the [native development section](https://github.com/MetaMask/metamask-mobile?tab=readme-ov-file#native-development) and run `yarn setup` and `yarn start:ios` as the .ipa will not work for now, we are working on having an .app that works on simulators. ##### [SOON] For external developers (we are testing the new dev builds and will make them publicly available soon after) ## **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. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 638b073f580..2f47d76233d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ yarn watch #### Download and install the development build -Go to the app's [GitHub Releases page](https://github.com/MetaMask/metamask-mobile/releases), download the latest release development build (android-expo-dev-build.apk or ios-expo-dev-build.ipa) and install it on an Android/iOS simulator or Android/iOS physical device. +#### For internal developers +- Access Runway via Okta and go to the Expo bucket either on the iOS or Android section. From there you will see the available development builds (android-expo-dev-build.apk or ios-expo-dev-build.ipa). +- For Android: + - Install the .apk on your Android device or simulator. +- For iOS: + - Device: you need to have your iPhone registered with our Apple dev account. If you have it, you can install the .ipa on your device. + - Simulator: please follow the [native development section](https://github.com/MetaMask/metamask-mobile?tab=readme-ov-file#native-development) and run `yarn setup` and `yarn start:ios` as the .ipa will not work for now, we are working on having an .app that works on simulators. + +##### [SOON] For external developers (we are testing the new dev builds and will make them publicly available soon after) #### Load the app From 65e28fc696e2ec8e44b6c0fbedd36534db3b8f7c Mon Sep 17 00:00:00 2001 From: Cal Leung Date: Mon, 9 Dec 2024 13:43:52 -0800 Subject: [PATCH 04/31] chore: Chore/12435 mvp handle engine does not exist (#12538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The main purpose of these changes is to ensure that the navigation service is available when Engine is initialized. The reason that this is needed is because it allows us to redirect instances of failed controller instances to vault recovery where users may at least recover their keys as opposed to being bricked. This is an example of the bricked state - https://github.com/MetaMask/metamask-mobile/pull/12115. This PR reorganizes the initialization of services, including Engine to sagas, where it enables us to wait for dependencies to load first. The two dependencies that the Engine relies on are: - Persisted data loaded - Navigation loaded ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/12435 ## **Manual testing steps** While the underlying logic changes, the app behavior should remain the same 1. Install the app on this branch 2. Create a wallet 3. Kill app, reopen and login 4. Should not experience any issues ## **Screenshots/Recordings** ### **Before** Simulates controller failed initialization https://github.com/user-attachments/assets/beb5e952-0fe3-4470-8aa6-2ff0947e3ffd ### **After** Simulates vault recovery when controller fails to initialize https://github.com/user-attachments/assets/76a853cd-34bc-465c-af03-d5c07609b8ee ## **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. --- .github/CODEOWNERS | 2 +- app/actions/navigation/index.ts | 30 ++- app/actions/navigation/types.ts | 31 +++ app/actions/user/index.js | 134 ---------- app/actions/user/index.ts | 161 ++++++++++++ app/actions/user/types.ts | 111 +++++++++ app/components/Nav/App/index.js | 7 + .../DeleteWallet/useDeleteWallet.test.tsx | 6 + app/core/AppStateEventListener.test.ts | 50 ++-- app/core/AppStateEventListener.ts | 35 ++- .../Authentication/Authentication.test.ts | 28 +-- app/core/Authentication/Authentication.ts | 49 +--- app/core/Engine/Engine.ts | 4 +- .../constants.ts | 0 .../utils.test.ts | 0 .../{accounts => AccountsController}/utils.ts | 0 .../Engine/controllers/accounts/README.md | 3 - app/core/EngineService/EngineService.test.ts | 107 +++++--- app/core/EngineService/EngineService.ts | 59 +++-- app/core/EngineService/index.ts | 3 +- app/core/LockManagerService/index.test.ts | 234 ++++++++++-------- app/core/LockManagerService/index.ts | 90 ++++--- app/core/redux/ReduxService.test.ts | 67 +++++ app/core/redux/ReduxService.ts | 49 ++++ app/core/redux/index.ts | 3 + app/core/redux/types.ts | 8 + app/reducers/index.ts | 11 +- app/reducers/navigation/index.ts | 38 +-- app/reducers/navigation/selectors.ts | 23 ++ app/reducers/navigation/types.ts | 8 + app/reducers/user/index.ts | 78 +++--- app/reducers/user/selectors.ts | 6 + app/reducers/user/types.ts | 19 ++ app/store/index.ts | 37 +-- app/store/persistConfig.ts | 4 +- app/store/sagas/index.ts | 61 +++-- app/store/sagas/sagas.test.ts | 78 +++++- app/util/test/initial-root-state.ts | 3 +- babel.config.js | 6 + package.json | 1 + yarn.lock | 19 ++ 41 files changed, 1060 insertions(+), 603 deletions(-) create mode 100644 app/actions/navigation/types.ts delete mode 100644 app/actions/user/index.js create mode 100644 app/actions/user/index.ts create mode 100644 app/actions/user/types.ts rename app/core/Engine/controllers/{accounts => AccountsController}/constants.ts (100%) rename app/core/Engine/controllers/{accounts => AccountsController}/utils.test.ts (100%) rename app/core/Engine/controllers/{accounts => AccountsController}/utils.ts (100%) delete mode 100644 app/core/Engine/controllers/accounts/README.md create mode 100644 app/core/redux/ReduxService.test.ts create mode 100644 app/core/redux/ReduxService.ts create mode 100644 app/core/redux/index.ts create mode 100644 app/core/redux/types.ts create mode 100644 app/reducers/navigation/selectors.ts create mode 100644 app/reducers/navigation/types.ts create mode 100644 app/reducers/user/selectors.ts create mode 100644 app/reducers/user/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 169e5ccc3dc..9576fde1dd0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,7 +47,7 @@ app/util/walletconnect.js @MetaMask/sdk-devs # Accounts Team app/core/Encryptor/ @MetaMask/accounts-engineers -app/core/Engine/controllers/accounts @MetaMask/accounts-engineers +app/core/Engine/controllers/AccountsController @MetaMask/accounts-engineers # Swaps Team app/components/UI/Swaps @MetaMask/swaps-engineers diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index 7a6fac7e9a0..b4c82b9a98e 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,18 +1,28 @@ /* eslint-disable import/prefer-default-export */ import { - SET_CURRENT_ROUTE, - SET_CURRENT_BOTTOM_NAV_ROUTE, -} from '../../reducers/navigation'; + type OnNavigationReadyAction, + type SetCurrentRouteAction, + type SetCurrentBottomNavRouteAction, + NavigationActionType, +} from './types'; -/** - * Action Creators - */ -export const setCurrentRoute = (route: string) => ({ - type: SET_CURRENT_ROUTE, +export * from './types'; + +export const setCurrentRoute = (route: string): SetCurrentRouteAction => ({ + type: NavigationActionType.SET_CURRENT_ROUTE, payload: { route }, }); -export const setCurrentBottomNavRoute = (route: string) => ({ - type: SET_CURRENT_BOTTOM_NAV_ROUTE, +export const setCurrentBottomNavRoute = ( + route: string, +): SetCurrentBottomNavRouteAction => ({ + type: NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE, payload: { route }, }); + +/** + * Action that is called when navigation is ready + */ +export const onNavigationReady = (): OnNavigationReadyAction => ({ + type: NavigationActionType.ON_NAVIGATION_READY, +}); diff --git a/app/actions/navigation/types.ts b/app/actions/navigation/types.ts new file mode 100644 index 00000000000..c57beba69da --- /dev/null +++ b/app/actions/navigation/types.ts @@ -0,0 +1,31 @@ +import { type Action } from 'redux'; + +/** + * Navigation action type enum + */ +export enum NavigationActionType { + ON_NAVIGATION_READY = 'ON_NAVIGATION_READY', + SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE', + SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_BOTTOM_NAV_ROUTE', +} + +export type OnNavigationReadyAction = + Action; + +export type SetCurrentRouteAction = + Action & { + payload: { route: string }; + }; + +export type SetCurrentBottomNavRouteAction = + Action & { + payload: { route: string }; + }; + +/** + * Navigation action + */ +export type NavigationAction = + | OnNavigationReadyAction + | SetCurrentRouteAction + | SetCurrentBottomNavRouteAction; diff --git a/app/actions/user/index.js b/app/actions/user/index.js deleted file mode 100644 index fd996b8707f..00000000000 --- a/app/actions/user/index.js +++ /dev/null @@ -1,134 +0,0 @@ -// Constants -export const LOCKED_APP = 'LOCKED_APP'; -export const AUTH_SUCCESS = 'AUTH_SUCCESS'; -export const AUTH_ERROR = 'AUTH_ERROR'; -export const INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS'; -export const LOGIN = 'LOGIN'; -export const LOGOUT = 'LOGOUT'; - -export function interruptBiometrics() { - return { - type: INTERRUPT_BIOMETRICS, - }; -} - -export function lockApp() { - return { - type: LOCKED_APP, - }; -} - -export function authSuccess(bioStateMachineId) { - return { - type: AUTH_SUCCESS, - payload: { bioStateMachineId }, - }; -} - -export function authError(bioStateMachineId) { - return { - type: AUTH_ERROR, - payload: { bioStateMachineId }, - }; -} - -export function passwordSet() { - return { - type: 'PASSWORD_SET', - }; -} - -export function passwordUnset() { - return { - type: 'PASSWORD_UNSET', - }; -} - -export function seedphraseBackedUp() { - return { - type: 'SEEDPHRASE_BACKED_UP', - }; -} - -export function seedphraseNotBackedUp() { - return { - type: 'SEEDPHRASE_NOT_BACKED_UP', - }; -} - -export function backUpSeedphraseAlertVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_VISIBLE', - }; -} - -export function backUpSeedphraseAlertNotVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', - }; -} - -export function protectWalletModalVisible() { - return { - type: 'PROTECT_MODAL_VISIBLE', - }; -} - -export function protectWalletModalNotVisible() { - return { - type: 'PROTECT_MODAL_NOT_VISIBLE', - }; -} - -export function loadingSet(loadingMsg) { - return { - type: 'LOADING_SET', - loadingMsg, - }; -} - -export function loadingUnset() { - return { - type: 'LOADING_UNSET', - }; -} - -export function setGasEducationCarouselSeen() { - return { - type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN', - }; -} - -export function logIn() { - return { - type: LOGIN, - }; -} - -export function logOut() { - return { - type: LOGOUT, - }; -} - -export function setAppTheme(theme) { - return { - type: 'SET_APP_THEME', - payload: { theme }, - }; -} - -/** - * Temporary action to control auth flow - * - * @param {string} initialScreen - "login" or "onboarding" - * @returns - void - */ -export function checkedAuth(initialScreen) { - return { - type: 'CHECKED_AUTH', - payload: { - initialScreen, - }, - }; -} diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts new file mode 100644 index 00000000000..9071fcffd50 --- /dev/null +++ b/app/actions/user/index.ts @@ -0,0 +1,161 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { + type InterruptBiometricsAction, + type LockAppAction, + type AuthSuccessAction, + type AuthErrorAction, + type PasswordSetAction, + type PasswordUnsetAction, + type SeedphraseBackedUpAction, + type SeedphraseNotBackedUpAction, + type BackUpSeedphraseVisibleAction, + type BackUpSeedphraseNotVisibleAction, + type ProtectModalVisibleAction, + type ProtectModalNotVisibleAction, + type LoadingSetAction, + type LoadingUnsetAction, + type SetGasEducationCarouselSeenAction, + type LoginAction, + type LogoutAction, + type SetAppThemeAction, + type CheckedAuthAction, + type PersistedDataLoadedAction, + UserActionType, +} from './types'; + +export * from './types'; + +export function interruptBiometrics(): InterruptBiometricsAction { + return { + type: UserActionType.INTERRUPT_BIOMETRICS, + }; +} + +export function lockApp(): LockAppAction { + return { + type: UserActionType.LOCKED_APP, + }; +} + +export function authSuccess(bioStateMachineId?: string): AuthSuccessAction { + return { + type: UserActionType.AUTH_SUCCESS, + payload: { bioStateMachineId }, + }; +} + +export function authError(bioStateMachineId?: string): AuthErrorAction { + return { + type: UserActionType.AUTH_ERROR, + payload: { bioStateMachineId }, + }; +} + +export function passwordSet(): PasswordSetAction { + return { + type: UserActionType.PASSWORD_SET, + }; +} + +export function passwordUnset(): PasswordUnsetAction { + return { + type: UserActionType.PASSWORD_UNSET, + }; +} + +export function seedphraseBackedUp(): SeedphraseBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_BACKED_UP, + }; +} + +export function seedphraseNotBackedUp(): SeedphraseNotBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_NOT_BACKED_UP, + }; +} + +export function backUpSeedphraseAlertVisible(): BackUpSeedphraseVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_VISIBLE, + }; +} + +export function backUpSeedphraseAlertNotVisible(): BackUpSeedphraseNotVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE, + }; +} + +export function protectWalletModalVisible(): ProtectModalVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_VISIBLE, + }; +} + +export function protectWalletModalNotVisible(): ProtectModalNotVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_NOT_VISIBLE, + }; +} + +export function loadingSet(loadingMsg: string): LoadingSetAction { + return { + type: UserActionType.LOADING_SET, + loadingMsg, + }; +} + +export function loadingUnset(): LoadingUnsetAction { + return { + type: UserActionType.LOADING_UNSET, + }; +} + +export function setGasEducationCarouselSeen(): SetGasEducationCarouselSeenAction { + return { + type: UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN, + }; +} + +export function logIn(): LoginAction { + return { + type: UserActionType.LOGIN, + }; +} + +export function logOut(): LogoutAction { + return { + type: UserActionType.LOGOUT, + }; +} + +export function setAppTheme(theme: AppThemeKey): SetAppThemeAction { + return { + type: UserActionType.SET_APP_THEME, + payload: { theme }, + }; +} + +/** + * Temporary action to control auth flow + * + * @param initialScreen - "login" or "onboarding" + */ +export function checkedAuth(initialScreen: string): CheckedAuthAction { + return { + type: UserActionType.CHECKED_AUTH, + payload: { + initialScreen, + }, + }; +} + +/** + * Action to signal that persisted data has been loaded + */ +export function onPersistedDataLoaded(): PersistedDataLoadedAction { + return { + type: UserActionType.ON_PERSISTED_DATA_LOADED, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts new file mode 100644 index 00000000000..704aee6092d --- /dev/null +++ b/app/actions/user/types.ts @@ -0,0 +1,111 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { type Action } from 'redux'; + +// Action type enum +export enum UserActionType { + LOCKED_APP = 'LOCKED_APP', + AUTH_SUCCESS = 'AUTH_SUCCESS', + AUTH_ERROR = 'AUTH_ERROR', + INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + ON_PERSISTED_DATA_LOADED = 'ON_PERSISTED_DATA_LOADED', + PASSWORD_SET = 'PASSWORD_SET', + PASSWORD_UNSET = 'PASSWORD_UNSET', + SEEDPHRASE_BACKED_UP = 'SEEDPHRASE_BACKED_UP', + SEEDPHRASE_NOT_BACKED_UP = 'SEEDPHRASE_NOT_BACKED_UP', + BACK_UP_SEEDPHRASE_VISIBLE = 'BACK_UP_SEEDPHRASE_VISIBLE', + BACK_UP_SEEDPHRASE_NOT_VISIBLE = 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', + PROTECT_MODAL_VISIBLE = 'PROTECT_MODAL_VISIBLE', + PROTECT_MODAL_NOT_VISIBLE = 'PROTECT_MODAL_NOT_VISIBLE', + LOADING_SET = 'LOADING_SET', + LOADING_UNSET = 'LOADING_UNSET', + SET_GAS_EDUCATION_CAROUSEL_SEEN = 'SET_GAS_EDUCATION_CAROUSEL_SEEN', + SET_APP_THEME = 'SET_APP_THEME', + CHECKED_AUTH = 'CHECKED_AUTH', +} + +// User actions +export type LockAppAction = Action; + +export type AuthSuccessAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type AuthErrorAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type InterruptBiometricsAction = + Action; + +export type LoginAction = Action; + +export type LogoutAction = Action; + +export type PersistedDataLoadedAction = + Action; + +export type PasswordSetAction = Action; + +export type PasswordUnsetAction = Action; + +export type SeedphraseBackedUpAction = + Action; + +export type SeedphraseNotBackedUpAction = + Action; + +export type BackUpSeedphraseVisibleAction = + Action; + +export type BackUpSeedphraseNotVisibleAction = + Action; + +export type ProtectModalVisibleAction = + Action; + +export type ProtectModalNotVisibleAction = + Action; + +export type LoadingSetAction = Action & { + loadingMsg: string; +}; + +export type LoadingUnsetAction = Action; + +export type SetGasEducationCarouselSeenAction = + Action; + +export type SetAppThemeAction = Action & { + payload: { theme: AppThemeKey }; +}; + +export type CheckedAuthAction = Action & { + payload: { initialScreen: string }; +}; + +/** + * User actions union type + */ +export type UserAction = + | LockAppAction + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction + | LoginAction + | LogoutAction + | PersistedDataLoadedAction + | PasswordSetAction + | PasswordUnsetAction + | SeedphraseBackedUpAction + | SeedphraseNotBackedUpAction + | BackUpSeedphraseVisibleAction + | BackUpSeedphraseNotVisibleAction + | ProtectModalVisibleAction + | ProtectModalNotVisibleAction + | LoadingSetAction + | LoadingUnsetAction + | SetGasEducationCarouselSeenAction + | SetAppThemeAction + | CheckedAuthAction; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 7218385de28..32ec533e3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -44,6 +44,7 @@ import { getVersion } from 'react-native-device-info'; import { setCurrentBottomNavRoute, setCurrentRoute, + onNavigationReady, } from '../../../actions/navigation'; import { findRouteNameFromNavigatorState } from '../../../util/general'; import { Authentication } from '../../../core/'; @@ -880,6 +881,11 @@ const App = (props) => { } }; + /** + * Triggers when the navigation is ready + */ + const onNavigationReadyHandler = () => dispatch(onNavigationReady()); + return supressRender ? null : ( <> { @@ -905,6 +911,7 @@ const App = (props) => { const currentRoute = findRouteNameFromNavigatorState(state.routes); triggerSetCurrentRoute(currentRoute); }} + onReady={onNavigationReadyHandler} > ({ removeItem: jest.fn(), })); +jest.mock('../../../core/redux/ReduxService', () => ({ + store: { + dispatch: jest.fn(), + }, +})); + describe('useDeleteWallet', () => { test('it should provide two outputs of type function', () => { const { result } = renderHook(() => useDeleteWallet()); diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts index 9940e4c2100..ede6491cc96 100644 --- a/app/core/AppStateEventListener.test.ts +++ b/app/core/AppStateEventListener.test.ts @@ -1,23 +1,12 @@ import { AppState, AppStateStatus } from 'react-native'; -import { store } from '../store'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { AppStateEventListener } from './AppStateEventListener'; import { processAttribution } from './processAttribution'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; +import ReduxService, { ReduxStore } from './redux'; -jest.mock('react-native', () => ({ - AppState: { - addEventListener: jest.fn(), - currentState: 'active', - }, -})); - -jest.mock('../store', () => ({ - store: { - getState: jest.fn(), - }, -})); +jest.mock('./DeeplinkManager/ParseManager/extractURLParams', () => jest.fn()); jest.mock('../util/Logger', () => ({ error: jest.fn(), @@ -44,6 +33,7 @@ describe('AppStateEventListener', () => { beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); jest.useFakeTimers(); (AppState.addEventListener as jest.Mock).mockImplementation( (_, listener) => { @@ -52,7 +42,7 @@ describe('AppStateEventListener', () => { }, ); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); }); afterEach(() => { @@ -66,16 +56,14 @@ describe('AppStateEventListener', () => { ); }); - it('throws error if store is initialized more than once', () => { - expect(() => appStateManager.init(store)).toThrow( - 'store is already initialized', - ); - expect(Logger.error).toHaveBeenCalledWith( - new Error('store is already initialized'), - ); + it('does not initialize event listener more than once', () => { + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); }); it('tracks event when app becomes active and attribution data is available', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const mockAttribution = { attributionId: 'test123', utm: 'test_utm', @@ -113,6 +101,9 @@ describe('AppStateEventListener', () => { }); it('handles errors gracefully', () => { + jest + .spyOn(ReduxService, 'store', 'get') + .mockReturnValue({} as unknown as ReduxStore); const testError = new Error('Test error'); (processAttribution as jest.Mock).mockImplementation(() => { throw testError; @@ -135,7 +126,7 @@ describe('AppStateEventListener', () => { }); appStateManager = new AppStateEventListener(); - appStateManager.init(store); + appStateManager.start(); appStateManager.cleanup(); expect(mockRemove).toHaveBeenCalled(); @@ -160,13 +151,22 @@ describe('AppStateEventListener', () => { }); it('should handle undefined store gracefully', () => { - appStateManager = new AppStateEventListener(); + const { processAttribution: realProcessAttribution } = jest.requireActual( + './processAttribution', + ); + (processAttribution as jest.Mock).mockImplementation( + realProcessAttribution, + ); + mockAppStateListener('active'); jest.advanceTimersByTime(2000); - expect(mockMetrics.trackEvent).not.toHaveBeenCalled(); + const missingReduxStoreError = new Error('Redux store does not exist!'); + const appStateManagerErrorMessage = + 'AppStateManager: Error processing app state change'; expect(Logger.error).toHaveBeenCalledWith( - new Error('store is not initialized'), + missingReduxStoreError, + appStateManagerErrorMessage, ); }); }); diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts index 9380aa585ea..2376c337494 100644 --- a/app/core/AppStateEventListener.ts +++ b/app/core/AppStateEventListener.ts @@ -1,36 +1,33 @@ import { AppState, AppStateStatus } from 'react-native'; -import { Store } from 'redux'; -import { RootState } from '../reducers'; import Logger from '../util/Logger'; import { MetaMetrics, MetaMetricsEvents } from './Analytics'; import { MetricsEventBuilder } from './Analytics/MetricsEventBuilder'; import { processAttribution } from './processAttribution'; import DevLogger from './SDKConnect/utils/DevLogger'; +import ReduxService from './redux'; export class AppStateEventListener { - private appStateSubscription: ReturnType; + private appStateSubscription: + | ReturnType + | undefined = undefined; private currentDeeplink: string | null = null; private lastAppState: AppStateStatus = AppState.currentState; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private store: Store | undefined; - constructor() { this.lastAppState = AppState.currentState; + } + + start() { + if (this.appStateSubscription) { + // Already started + return; + } this.appStateSubscription = AppState.addEventListener( 'change', this.handleAppStateChange, ); } - init(store: Store) { - if (this.store) { - Logger.error(new Error('store is already initialized')); - throw new Error('store is already initialized'); - } - this.store = store; - } - public setCurrentDeeplink(deeplink: string | null) { this.currentDeeplink = deeplink; } @@ -46,15 +43,10 @@ export class AppStateEventListener { }; private processAppStateChange = () => { - if (!this.store) { - Logger.error(new Error('store is not initialized')); - return; - } - try { const attribution = processAttribution({ currentDeeplink: this.currentDeeplink, - store: this.store, + store: ReduxService.store, }); if (attribution) { const { attributionId, utm, ...utmParams } = attribution; @@ -77,7 +69,8 @@ export class AppStateEventListener { }; public cleanup() { - this.appStateSubscription.remove(); + this.appStateSubscription?.remove(); + this.appStateSubscription = undefined; } } diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts index e8be46072c4..b268825ad18 100644 --- a/app/core/Authentication/Authentication.test.ts +++ b/app/core/Authentication/Authentication.test.ts @@ -10,8 +10,7 @@ import AUTHENTICATION_TYPE from '../../constants/userProperties'; // eslint-disable-next-line import/no-namespace import * as Keychain from 'react-native-keychain'; import SecureKeychain from '../SecureKeychain'; -import configureMockStore from 'redux-mock-store'; -import Logger from '../../util/Logger'; +import ReduxService, { ReduxStore } from '../redux'; const storage: Record = {}; @@ -32,32 +31,11 @@ jest.mock('../../store/storage-wrapper', () => ({ })); describe('Authentication', () => { - const initialState = { - security: { - allowLoginWithRememberMe: true, - }, - }; - const mockStore = configureMockStore(); - const store = mockStore(initialState); - - beforeEach(() => { - Authentication.init(store); - }); - afterEach(() => { StorageWrapper.clearAll(); jest.restoreAllMocks(); }); - it('Does not initialize class more than once', async () => { - const spy = jest.spyOn(Logger, 'log'); - Authentication.init(store); - Authentication.init(store); - expect(spy).toHaveBeenCalledWith( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - }); - it('should return a type password', async () => { SecureKeychain.getSupportedBiometryType = jest .fn() @@ -129,6 +107,10 @@ describe('Authentication', () => { }); it('should return a auth type for components AUTHENTICATION_TYPE.REMEMBER_ME', async () => { + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ security: { allowLoginWithRememberMe: true } }), + } as unknown as ReduxStore); + SecureKeychain.getSupportedBiometryType = jest .fn() .mockReturnValue(Keychain.BIOMETRY_TYPE.FINGERPRINT); diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index beba46cf7c6..ea4bf865def 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -7,7 +7,6 @@ import { PASSCODE_DISABLED, SEED_PHRASE_HINTS, } from '../../constants/storage'; -import Logger from '../../util/Logger'; import { authSuccess, authError, @@ -16,7 +15,6 @@ import { passwordSet, } from '../../actions/user'; import AUTHENTICATION_TYPE from '../../constants/userProperties'; -import { Store } from 'redux'; import AuthenticationError from './AuthenticationError'; import { UserCredentials, BIOMETRY_TYPE } from 'react-native-keychain'; import { @@ -32,6 +30,7 @@ import StorageWrapper from '../../store/storage-wrapper'; import NavigationService from '../NavigationService'; import Routes from '../../constants/navigation/Routes'; import { TraceName, TraceOperation, endTrace, trace } from '../../util/trace'; +import ReduxService from '../redux'; /** * Holds auth data used to determine auth configuration @@ -43,51 +42,17 @@ export interface AuthData { class AuthenticationService { private authData: AuthData = { currentAuthType: AUTHENTICATION_TYPE.UNKNOWN }; - private store: Store | undefined = undefined; - private static isInitialized = false; - - /** - * This method creates the instance of the authentication class - * @param {Store} store - A redux function that will dispatch global state actions - */ - init(store: Store) { - if (!AuthenticationService.isInitialized) { - AuthenticationService.isInitialized = true; - this.store = store; - } else { - Logger.log( - 'Attempted to call init on AuthenticationService but an instance has already been initialized', - ); - } - } private dispatchLogin(): void { - if (this.store) { - this.store.dispatch(logIn()); - } else { - Logger.log( - 'Attempted to dispatch logIn action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(logIn()); } private dispatchPasswordSet(): void { - if (this.store) { - this.store.dispatch(passwordSet()); - } else { - Logger.log( - 'Attempted to dispatch passwordSet action but dispatch was not initialized', - ); - } + ReduxService.store.dispatch(passwordSet()); } private dispatchLogout(): void { - if (this.store) { - this.store.dispatch(logOut()); - } else - Logger.log( - 'Attempted to dispatch logOut action but dispatch was not initialized', - ); + ReduxService.store.dispatch(logOut()); } /** @@ -307,7 +272,7 @@ class AuthenticationService { }; } else if ( rememberMe && - this.store?.getState().security.allowLoginWithRememberMe + ReduxService.store.getState().security.allowLoginWithRememberMe ) { return { currentAuthType: AUTHENTICATION_TYPE.REMEMBER_ME, @@ -460,12 +425,12 @@ class AuthenticationService { endTrace({ name: TraceName.VaultCreation }); this.dispatchLogin(); - this.store?.dispatch(authSuccess(bioStateMachineId)); + ReduxService.store.dispatch(authSuccess(bioStateMachineId)); this.dispatchPasswordSet(); // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { - this.store?.dispatch(authError(bioStateMachineId)); + ReduxService.store.dispatch(authError(bioStateMachineId)); !disableAutoLogout && this.lockApp({ reset: false }); throw new AuthenticationError( (e as Error).message, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 294baf2b96f..4e21df41a30 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -154,9 +154,9 @@ import { AccountsControllerSelectedAccountChangeEvent, AccountsControllerAccountAddedEvent, AccountsControllerAccountRenamedEvent, -} from './controllers/accounts/constants'; +} from './controllers/AccountsController/constants'; import { AccountsControllerMessenger } from '@metamask/accounts-controller'; -import { createAccountsController } from './controllers/accounts/utils'; +import { createAccountsController } from './controllers/AccountsController/utils'; import { createRemoteFeatureFlagController } from './controllers/RemoteFeatureFlagController'; import { captureException } from '@sentry/react-native'; import { lowerCase } from 'lodash'; diff --git a/app/core/Engine/controllers/accounts/constants.ts b/app/core/Engine/controllers/AccountsController/constants.ts similarity index 100% rename from app/core/Engine/controllers/accounts/constants.ts rename to app/core/Engine/controllers/AccountsController/constants.ts diff --git a/app/core/Engine/controllers/accounts/utils.test.ts b/app/core/Engine/controllers/AccountsController/utils.test.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.test.ts rename to app/core/Engine/controllers/AccountsController/utils.test.ts diff --git a/app/core/Engine/controllers/accounts/utils.ts b/app/core/Engine/controllers/AccountsController/utils.ts similarity index 100% rename from app/core/Engine/controllers/accounts/utils.ts rename to app/core/Engine/controllers/AccountsController/utils.ts diff --git a/app/core/Engine/controllers/accounts/README.md b/app/core/Engine/controllers/accounts/README.md deleted file mode 100644 index 24ea735bd59..00000000000 --- a/app/core/Engine/controllers/accounts/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Controllers owned by the Accounts team - -This folder contains controllers that are owned by the Accounts team. diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index 20ee32ab5bf..99bc238b2f5 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -1,39 +1,40 @@ -import EngineService from './EngineService'; +import { EngineService } from './EngineService'; +import ReduxService, { type ReduxStore } from '../redux'; import Engine from '../Engine'; -import { store } from '../../store'; +import { type KeyringControllerState } from '@metamask/keyring-controller'; +import NavigationService from '../NavigationService'; +import Logger from '../../util/Logger'; +import Routes from '../../constants/navigation/Routes'; -jest.mock('../../util/test/network-store.js', () => jest.fn()); -jest.mock('../../store', () => ({ - store: { - getState: jest.fn(() => ({ - engine: { - backgroundState: {}, - }, - })), - dispatch: jest.fn(), +// Mock NavigationService +jest.mock('../NavigationService', () => ({ + navigation: { + reset: jest.fn(), }, })); +// Mock Logger +jest.mock('../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../BackupVault', () => ({ + getVaultFromBackup: () => ({ success: true, vault: 'fake_vault' }), +})); + +jest.mock('../../util/test/network-store.js', () => jest.fn()); + +// Unmock global Engine +jest.unmock('../Engine'); + jest.mock('../Engine', () => { // Do not need to mock entire Engine. Only need subset of data for testing purposes. // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any let instance: any; - return { - get context() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.context; - }, - get controllerMessenger() { - if (!instance) { - throw new Error('Engine does not exist'); - } - return instance.controllerMessenger; - }, - destroyEngine: jest.fn(), - init: jest.fn((_, keyringState) => { + + const mockEngine = { + init: (_: unknown, keyringState: KeyringControllerState) => { instance = { controllerMessenger: { subscribe: jest.fn(), @@ -77,18 +78,68 @@ jest.mock('../Engine', () => { }, }; return instance; + }, + get context() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.context; + }, + get controllerMessenger() { + if (!instance) { + throw new Error('Engine does not exist'); + } + return instance.controllerMessenger; + }, + destroyEngine: jest.fn(async () => { + instance = null; }), }; + + return { + __esModule: true, + default: mockEngine, + }; }); describe('EngineService', () => { - EngineService.initalizeEngine(store); + let engineService: EngineService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ engine: { backgroundState: {} } }), + } as unknown as ReduxStore); + + engineService = new EngineService(); + }); + it('should have Engine initialized', () => { + engineService.start(); expect(Engine.context).toBeDefined(); }); + it('should have recovered vault on redux store ', async () => { - const { success } = await EngineService.initializeVaultFromBackup(); + engineService.start(); + const { success } = await engineService.initializeVaultFromBackup(); expect(success).toBeTruthy(); expect(Engine.context.KeyringController.state.vault).toBeDefined(); }); + + it('should navigate to vault recovery if Engine fails to initialize', () => { + jest.spyOn(Engine, 'init').mockImplementation(() => { + throw new Error('Failed to initialize Engine'); + }); + engineService.start(); + // Logs error to Sentry + expect(Logger.error).toHaveBeenCalledWith( + new Error('Failed to initialize Engine'), + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigates to vault recovery + expect(NavigationService.navigation?.reset).toHaveBeenCalledWith({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + }); }); diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index bedb3e97534..6ba3e827cb9 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -1,7 +1,6 @@ import UntypedEngine from '../Engine'; import AppConstants from '../AppConstants'; import { getVaultFromBackup } from '../BackupVault'; -import { store as importedStore } from '../../store'; import Logger from '../../util/Logger'; import { NO_VAULT_IN_BACKUP_ERROR, @@ -10,6 +9,9 @@ import { import { getTraceTags } from '../../util/sentry/tags'; import { trace, endTrace, TraceName, TraceOperation } from '../../util/trace'; import getUIStartupSpan from '../Performance/UIStartup'; +import ReduxService from '../redux'; +import NavigationService from '../NavigationService'; +import Routes from '../../constants/navigation/Routes'; interface InitializeEngineResult { success: boolean; @@ -18,37 +20,57 @@ interface InitializeEngineResult { const UPDATE_BG_STATE_KEY = 'UPDATE_BG_STATE'; const INIT_BG_STATE_KEY = 'INIT_BG_STATE'; -class EngineService { +export class EngineService { private engineInitialized = false; /** - * Initializer for the EngineService + * Starts the Engine and subscribes to the controller state changes * - * @param store - Redux store + * EngineService.start() with SES/lockdown: + * Requires ethjs nested patches (lib->src) + * - ethjs/ethjs-query + * - ethjs/ethjs-contract + * Otherwise causing the following errors: + * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' + * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: + * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' + * - JSC: Attempted to assign to readonly property + * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js + * - V8: TypeError: _$$_REQUIRE(...) is not a constructor + * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') + * - V8: SES_UNHANDLED_REJECTION */ - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - initalizeEngine = (store: any) => { + start = () => { + const reduxState = ReduxService.store.getState(); trace({ name: TraceName.EngineInitialization, op: TraceOperation.EngineInitialization, parentContext: getUIStartupSpan(), - tags: getTraceTags(store.getState()), + tags: getTraceTags(reduxState), }); - const reduxState = store.getState?.(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const Engine = UntypedEngine as any; - Engine.init(state); - this.updateControllers(store, Engine); + try { + Engine.init(state); + this.updateControllers(Engine); + } catch (error) { + Logger.error( + error as Error, + 'Failed to initialize Engine! Falling back to vault recovery.', + ); + // Navigate to vault recovery + NavigationService.navigation?.reset({ + routes: [{ name: Routes.VAULT_RECOVERY.RESTORE_WALLET }], + }); + } endTrace({ name: TraceName.EngineInitialization }); }; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - private updateControllers = (store: any, engine: any) => { + private updateControllers = (engine: any) => { if (!engine.context) { Logger.error( new Error( @@ -179,7 +201,7 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for INIT_BG_STATE_KEY'); } - store.dispatch({ type: INIT_BG_STATE_KEY }); + ReduxService.store.dispatch({ type: INIT_BG_STATE_KEY }); this.engineInitialized = true; }, () => !this.engineInitialized, @@ -191,7 +213,10 @@ class EngineService { if (!engine.context.KeyringController.metadata.vault) { Logger.log('keyringController vault missing for UPDATE_BG_STATE_KEY'); } - store.dispatch({ type: UPDATE_BG_STATE_KEY, payload: { key: name } }); + ReduxService.store.dispatch({ + type: UPDATE_BG_STATE_KEY, + payload: { key: name }, + }); }; engine.controllerMessenger.subscribe(key, update_bg_state_cb); }); @@ -208,7 +233,7 @@ class EngineService { */ async initializeVaultFromBackup(): Promise { const keyringState = await getVaultFromBackup(); - const reduxState = importedStore.getState?.(); + const reduxState = ReduxService.store.getState(); const state = reduxState?.engine?.backgroundState || {}; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -223,7 +248,7 @@ class EngineService { }; const instance = Engine.init(state, newKeyringState); if (instance) { - this.updateControllers(importedStore, instance); + this.updateControllers(instance); // this is a hack to give the engine time to reinitialize await new Promise((resolve) => setTimeout(resolve, 2000)); return { diff --git a/app/core/EngineService/index.ts b/app/core/EngineService/index.ts index 9bed417ddf2..dd9072d50ea 100644 --- a/app/core/EngineService/index.ts +++ b/app/core/EngineService/index.ts @@ -1,2 +1 @@ -import EngineService from './EngineService'; -export default EngineService; +export { default } from './EngineService'; diff --git a/app/core/LockManagerService/index.test.ts b/app/core/LockManagerService/index.test.ts index a562ab57efe..4924e5d479d 100644 --- a/app/core/LockManagerService/index.test.ts +++ b/app/core/LockManagerService/index.test.ts @@ -1,7 +1,8 @@ -import LockManagerService from '.'; -import { AppState } from 'react-native'; -import configureMockStore from 'redux-mock-store'; +import { LockManagerService } from '.'; +import { AppState, AppStateStatus } from 'react-native'; import { interruptBiometrics, lockApp } from '../../actions/user'; +import Logger from '../../util/Logger'; +import ReduxService, { type ReduxStore } from '../redux'; jest.mock('../Engine', () => ({ context: { @@ -10,133 +11,148 @@ jest.mock('../Engine', () => ({ }, }, })); + const mockSetTimeout = jest.fn(); + jest.mock('react-native-background-timer', () => ({ setTimeout: () => mockSetTimeout(), })); + jest.mock('../SecureKeychain', () => ({ getInstance: () => ({ isAuthenticating: false, }), })); -const initialState = { - settings: { - lockTime: 0, - }, -}; -const mockStore = configureMockStore(); -const defaultStore = mockStore(initialState); - -describe('startListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing when store is undefined.', async () => { - LockManagerService.startListening(); - expect(addEventListener).not.toBeCalled(); - }); - - it('should do nothing when app state listener is already subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - }); - - it('should add event listener when store is defined and listener is not yet subscribed.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalled(); - }); -}); +jest.mock('../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); -describe('stopListening', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); +describe('LockManagerService', () => { + let lockManagerService: LockManagerService; + let mockAppStateListener: (state: AppStateStatus) => void; - afterEach(() => { - LockManagerService.stopListening(); + beforeEach(() => { jest.clearAllMocks(); + jest.resetModules(); + jest.useFakeTimers(); + (AppState.addEventListener as jest.Mock).mockImplementation( + (_, listener) => { + mockAppStateListener = listener; + return { remove: jest.fn() }; + }, + ); + lockManagerService = new LockManagerService(); }); - it('should remove app state listener.', async () => { - LockManagerService.init(defaultStore); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(1); - LockManagerService.stopListening(); - LockManagerService.startListening(); - expect(addEventListener).toBeCalledTimes(2); - }); -}); - -describe('handleAppStateChange', () => { - const addEventListener = jest.spyOn(AppState, 'addEventListener'); - const defaultDispatch = jest.spyOn(defaultStore, 'dispatch'); - afterEach(() => { - LockManagerService.stopListening(); - jest.clearAllMocks(); - }); - - it('should do nothing if lockTime is -1 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: -1 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing if lockTime is 0 while going inactive.', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - expect(defaultDispatch).not.toBeCalled(); - }); - - it('should do nothing while lockTime is 0 while going from inactive to active', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('inactive'); - appStateTrigger('active'); - expect(defaultDispatch).not.toBeCalled(); + lockManagerService.stopListening(); + jest.useRealTimers(); }); - it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(dispatch).toBeCalledWith(interruptBiometrics()); + describe('startListening', () => { + it('should do nothing when app state listener is already subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + expect(Logger.log).toHaveBeenCalledWith( + 'Already subscribed to app state listener.', + ); + }); + + it('should add event listener when it is not yet subscribed.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalled(); + }); }); - it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 0 } }); - const dispatch = jest.spyOn(store, 'dispatch'); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(await dispatch).toBeCalledWith(lockApp()); + describe('stopListening', () => { + it('should remove app state listener.', async () => { + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + lockManagerService.stopListening(); + lockManagerService.startListening(); + expect(AppState.addEventListener).toHaveBeenCalledTimes(2); + }); }); - it('should set background timer when lockTimer is non-zero while going into the background', async () => { - const store = mockStore({ settings: { lockTime: 5 } }); - LockManagerService.init(store); - LockManagerService.startListening(); - const appStateTrigger = addEventListener.mock.calls[0][1]; - appStateTrigger('background'); - expect(mockSetTimeout).toBeCalled(); + describe('handleAppStateChange', () => { + it('should throw an error if store is undefined.', async () => { + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + 'LockManagerService: Error handling app state change', + ); + }); + + it('should do nothing if lockTime is -1 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: -1 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing if lockTime is 0 while going inactive.', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should do nothing while lockTime is 0 while going from inactive to active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('inactive'); + mockAppStateListener('active'); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should dispatch interruptBiometrics when lockTimer is undefined, lockTime is non-zero, and app state is not active', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockDispatch).toHaveBeenCalledWith(interruptBiometrics()); + }); + + it('should dispatch lockApp when lockTimer is 0 while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 0 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(await mockDispatch).toHaveBeenCalledWith(lockApp()); + }); + + it('should set background timer when lockTimer is non-zero while going into the background', async () => { + const mockDispatch = jest.fn(); + jest.spyOn(ReduxService, 'store', 'get').mockReturnValue({ + getState: () => ({ settings: { lockTime: 5 } }), + dispatch: mockDispatch, + } as unknown as ReduxStore); + lockManagerService.startListening(); + mockAppStateListener('background'); + expect(mockSetTimeout).toHaveBeenCalled(); + }); }); }); diff --git a/app/core/LockManagerService/index.ts b/app/core/LockManagerService/index.ts index a5f0b346827..0fba5c0978a 100644 --- a/app/core/LockManagerService/index.ts +++ b/app/core/LockManagerService/index.ts @@ -8,26 +8,19 @@ import BackgroundTimer from 'react-native-background-timer'; import Engine from '../Engine'; import Logger from '../../util/Logger'; import { lockApp, interruptBiometrics } from '../../actions/user'; -import { Store } from 'redux'; +import ReduxService from '../redux'; -class LockManagerService { +export class LockManagerService { #appState?: AppStateStatus; #appStateListener?: NativeEventSubscription; #lockTimer?: number; - #store?: Store; - - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - init = (store: any) => { - this.#store = store; - }; #lockApp = async () => { if (!SecureKeychain.getInstance().isAuthenticating) { const { KeyringController } = Engine.context; try { await KeyringController.setLocked(); - this.#store?.dispatch(lockApp()); + ReduxService.store.dispatch(lockApp()); } catch (error) { Logger.log('Failed to lock KeyringController', error); } @@ -47,55 +40,58 @@ class LockManagerService { #handleAppStateChange = async (nextAppState: AppStateStatus) => { // Don't auto-lock. - const lockTime = this.#store?.getState().settings.lockTime; - if ( - lockTime === -1 || // Lock timer isn't set. - nextAppState === 'inactive' || // Ignore inactive state. - (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. - ) { - this.#appState = nextAppState; - return; - } + try { + const lockTime = ReduxService.store.getState().settings.lockTime; + if ( + lockTime === -1 || // Lock timer isn't set. + nextAppState === 'inactive' || // Ignore inactive state. + (this.#appState === 'inactive' && nextAppState === 'active') // Ignore going from inactive -> active state. + ) { + this.#appState = nextAppState; + return; + } - // EDGE CASE - // Handles interruptions in the middle of authentication while lock timer is a non-zero value - // This is most likely called when the background timer fails to be called while backgrounding the app - if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { - this.#store?.dispatch(interruptBiometrics()); - } + // EDGE CASE + // Handles interruptions in the middle of authentication while lock timer is a non-zero value + // This is most likely called when the background timer fails to be called while backgrounding the app + if (!this.#lockTimer && lockTime !== 0 && nextAppState !== 'active') { + ReduxService.store.dispatch(interruptBiometrics()); + } - // Handle lock logic on background. - if (nextAppState === 'background') { - if (lockTime === 0) { - this.#lockApp(); - } else { - // Autolock after some time. + // Handle lock logic on background. + if (nextAppState === 'background') { + if (lockTime === 0) { + this.#lockApp(); + } else { + // Autolock after some time. + this.#clearBackgroundTimer(); + this.#lockTimer = BackgroundTimer.setTimeout(() => { + if (this.#lockTimer) { + this.#lockApp(); + } + }, lockTime); + } + } + + // App has foregrounded from background. + // Clear background timer for safe measure. + if (nextAppState === 'active') { this.#clearBackgroundTimer(); - this.#lockTimer = BackgroundTimer.setTimeout(() => { - if (this.#lockTimer) { - this.#lockApp(); - } - }, lockTime); } - } - // App has foregrounded from background. - // Clear background timer for safe measure. - if (nextAppState === 'active') { - this.#clearBackgroundTimer(); + this.#appState = nextAppState; + } catch (error) { + Logger.error( + error as Error, + 'LockManagerService: Error handling app state change', + ); } - - this.#appState = nextAppState; }; /** * Listen to AppState events to control lock state. */ startListening = () => { - if (!this.#store) { - Logger.log('Failed to start listener since store is undefined.'); - return; - } if (this.#appStateListener) { Logger.log('Already subscribed to app state listener.'); return; diff --git a/app/core/redux/ReduxService.test.ts b/app/core/redux/ReduxService.test.ts new file mode 100644 index 00000000000..7b14a872c07 --- /dev/null +++ b/app/core/redux/ReduxService.test.ts @@ -0,0 +1,67 @@ +import ReduxService from './ReduxService'; +import Logger from '../../util/Logger'; +import type { ReduxStore } from './types'; + +describe('ReduxService', () => { + let mockStore: ReduxStore; + + beforeEach(() => { + // Reset any internal state + jest.clearAllMocks(); + + // Create a mock store + mockStore = { + dispatch: jest.fn(), + getState: jest.fn(), + subscribe: jest.fn(), + replaceReducer: jest.fn(), + } as unknown as ReduxStore; + + // Spy on Logger + jest.spyOn(Logger, 'error'); + }); + + describe('store getter', () => { + it('should throw error if store does not exist', () => { + expect(() => ReduxService.store).toThrow('Redux store does not exist!'); + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store does not exist!'), + ); + }); + + it('should return store if it exists', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + }); + + describe('store setter', () => { + it('should throw error if store is invalid', () => { + const invalidStore = {} as ReduxStore; + + expect(() => { + ReduxService.store = invalidStore; + }).toThrow('Redux store is not a valid store!'); + + expect(Logger.error).toHaveBeenCalledWith( + new Error('Redux store is not a valid store!'), + ); + }); + + it('should set store if valid', () => { + ReduxService.store = mockStore; + expect(ReduxService.store).toBe(mockStore); + }); + + it('should validate store has required methods', () => { + const incompleteStore = { + dispatch: jest.fn(), + // missing getState + } as unknown as ReduxStore; + + expect(() => { + ReduxService.store = incompleteStore; + }).toThrow('Redux store is not a valid store!'); + }); + }); +}); diff --git a/app/core/redux/ReduxService.ts b/app/core/redux/ReduxService.ts new file mode 100644 index 00000000000..7f2d9f75fa6 --- /dev/null +++ b/app/core/redux/ReduxService.ts @@ -0,0 +1,49 @@ +import Logger from '../../util/Logger'; +import { ReduxStore } from './types'; + +/** + * ReduxService class that manages the Redux store + */ +class ReduxService { + static #reduxStore: ReduxStore; + + static #assertReduxStoreType(store: ReduxStore) { + if ( + typeof store.dispatch !== 'function' || + typeof store.getState !== 'function' + ) { + const error = new Error('Redux store is not a valid store!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + static #assertReduxStoreExists() { + if (!this.#reduxStore) { + const error = new Error('Redux store does not exist!'); + Logger.error(error); + throw error; + } + return this.#reduxStore; + } + + /** + * Set the store in the Redux class + * @param store + */ + static set store(store: ReduxStore) { + this.#assertReduxStoreType(store); + this.#reduxStore = store; + } + + /** + * Get the store from the Redux class + */ + static get store() { + this.#assertReduxStoreExists(); + return this.#reduxStore; + } +} + +export default ReduxService; diff --git a/app/core/redux/index.ts b/app/core/redux/index.ts new file mode 100644 index 00000000000..d3b1d8b7418 --- /dev/null +++ b/app/core/redux/index.ts @@ -0,0 +1,3 @@ +export { default } from './ReduxService'; + +export * from './types'; diff --git a/app/core/redux/types.ts b/app/core/redux/types.ts new file mode 100644 index 00000000000..09fff70ce7e --- /dev/null +++ b/app/core/redux/types.ts @@ -0,0 +1,8 @@ +import { AnyAction, Store } from 'redux'; +import { RootState } from '../../reducers'; + +/** + * Redux store type + * TODO: Replace AnyAction with union type of all actions + */ +export type ReduxStore = Store; diff --git a/app/reducers/index.ts b/app/reducers/index.ts index ee998f4724f..990bc742032 100644 --- a/app/reducers/index.ts +++ b/app/reducers/index.ts @@ -7,7 +7,7 @@ import settingsReducer from './settings'; import alertReducer from './alert'; import transactionReducer from './transaction'; import legalNoticesReducer from './legalNotices'; -import userReducer, { IUserReducer } from './user'; +import userReducer, { UserState } from './user'; import wizardReducer from './wizard'; import onboardingReducer from './onboarding'; import fiatOrders from './fiatOrders'; @@ -16,7 +16,7 @@ import signatureRequestReducer from './signatureRequest'; import notificationReducer from './notification'; import infuraAvailabilityReducer from './infuraAvailability'; import collectiblesReducer from './collectibles'; -import navigationReducer from './navigation'; +import navigationReducer, { NavigationState } from './navigation'; import networkOnboardReducer from './networkSelector'; import securityReducer, { SecurityState } from './security'; import { combineReducers, Reducer } from 'redux'; @@ -81,7 +81,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any transaction: any; - user: IUserReducer; + user: UserState; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any wizard: any; @@ -98,10 +98,7 @@ export interface RootState { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any infuraAvailability: any; - // The navigation reducer is TypeScript but not yet a valid reducer - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigation: any; + navigation: NavigationState; // The networkOnboarded reducer is TypeScript but not yet a valid reducer // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/app/reducers/navigation/index.ts b/app/reducers/navigation/index.ts index 9078531b0cb..4cdf8a0558f 100644 --- a/app/reducers/navigation/index.ts +++ b/app/reducers/navigation/index.ts @@ -1,32 +1,36 @@ -/** - * Constants - */ -export const SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE'; -export const SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_TAB_BAR_ROUTE'; +import { + type NavigationAction, + NavigationActionType, +} from '../../actions/navigation/types'; +import { NavigationState } from './types'; + +export * from './types'; + +export * from './selectors'; /** - * Reducers + * Initial navigation state */ -interface InitialState { - currentRoute: string; - currentBottomNavRoute: string; -} - -const initialState: InitialState = { +export const initialNavigationState: NavigationState = { currentRoute: 'WalletView', currentBottomNavRoute: 'Wallet', }; -// TODO: Replace "any" with type -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const navigationReducer = (state = initialState, action: any = {}) => { +/** + * Navigation reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ +const navigationReducer = ( + state: NavigationState = initialNavigationState, + action: NavigationAction, +): NavigationState => { switch (action.type) { - case SET_CURRENT_ROUTE: + case NavigationActionType.SET_CURRENT_ROUTE: return { ...state, currentRoute: action.payload.route, }; - case SET_CURRENT_BOTTOM_NAV_ROUTE: + case NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE: return { ...state, currentBottomNavRoute: action.payload.route, diff --git a/app/reducers/navigation/selectors.ts b/app/reducers/navigation/selectors.ts new file mode 100644 index 00000000000..a85aaa1cac0 --- /dev/null +++ b/app/reducers/navigation/selectors.ts @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; +import { RootState } from '..'; + +/** + * Selects the navigation state + */ +export const selectNavigationState = (state: RootState) => state.navigation; + +/** + * Selects the current route + */ +export const selectCurrentRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentRoute, +); + +/** + * Selects the current bottom nav route + */ +export const selectCurrentBottomNavRoute = createSelector( + selectNavigationState, + (navigationState) => navigationState.currentBottomNavRoute, +); diff --git a/app/reducers/navigation/types.ts b/app/reducers/navigation/types.ts new file mode 100644 index 00000000000..b5f22b005e6 --- /dev/null +++ b/app/reducers/navigation/types.ts @@ -0,0 +1,8 @@ +/** + * Navigation state + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type NavigationState = { + currentRoute: string; + currentBottomNavRoute: string; +}; diff --git a/app/reducers/user/index.ts b/app/reducers/user/index.ts index a942f0eb88d..5941561d6ea 100644 --- a/app/reducers/user/index.ts +++ b/app/reducers/user/index.ts @@ -1,21 +1,15 @@ +import { UserAction, UserActionType } from '../../actions/user/types'; import { AppThemeKey } from '../../util/theme/models'; +import { UserState } from './types'; -export interface IUserReducer { - loadingMsg: string; - loadingSet: boolean; - passwordSet: boolean; - seedphraseBackedUp: boolean; - backUpSeedphraseVisible: boolean; - protectWalletModalVisible: boolean; - gasEducationCarouselSeen: boolean; - userLoggedIn: boolean; - isAuthChecked: boolean; - initialScreen: string; - appTheme: AppThemeKey; - ambiguousAddressEntries: Record; -} +export * from './types'; -export const userInitialState = { +export * from './selectors'; + +/** + * Initial user state + */ +export const userInitialState: UserState = { loadingMsg: '', loadingSet: false, passwordSet: false, @@ -30,83 +24,69 @@ export const userInitialState = { ambiguousAddressEntries: {}, }; -// Define action types -type UserAction = - | { type: 'LOGIN' } - | { type: 'LOGOUT' } - | { type: 'LOADING_SET'; loadingMsg: string } - | { type: 'LOADING_UNSET' } - | { type: 'PASSWORD_SET' } - | { type: 'PASSWORD_UNSET' } - | { type: 'SEEDPHRASE_NOT_BACKED_UP' } - | { type: 'SEEDPHRASE_BACKED_UP' } - | { type: 'BACK_UP_SEEDPHRASE_VISIBLE' } - | { type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE' } - | { type: 'PROTECT_MODAL_VISIBLE' } - | { type: 'PROTECT_MODAL_NOT_VISIBLE' } - | { type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN' } - | { type: 'SET_APP_THEME'; payload: { theme: AppThemeKey } }; - +/** + * User reducer + */ +/* eslint-disable @typescript-eslint/default-param-last */ const userReducer = ( - // eslint-disable-next-line @typescript-eslint/default-param-last - state: IUserReducer = userInitialState, + state: UserState = userInitialState, action: UserAction, -) => { +): UserState => { switch (action.type) { - case 'LOGIN': + case UserActionType.LOGIN: return { ...state, userLoggedIn: true, }; - case 'LOGOUT': + case UserActionType.LOGOUT: return { ...state, userLoggedIn: false, }; - case 'LOADING_SET': + case UserActionType.LOADING_SET: return { ...state, loadingSet: true, loadingMsg: action.loadingMsg, }; - case 'LOADING_UNSET': + case UserActionType.LOADING_UNSET: return { ...state, loadingSet: false, }; - case 'PASSWORD_SET': + case UserActionType.PASSWORD_SET: return { ...state, passwordSet: true, }; - case 'PASSWORD_UNSET': + case UserActionType.PASSWORD_UNSET: return { ...state, passwordSet: false, }; - case 'SEEDPHRASE_NOT_BACKED_UP': + case UserActionType.SEEDPHRASE_NOT_BACKED_UP: return { ...state, seedphraseBackedUp: false, backUpSeedphraseVisible: true, }; - case 'SEEDPHRASE_BACKED_UP': + case UserActionType.SEEDPHRASE_BACKED_UP: return { ...state, seedphraseBackedUp: true, backUpSeedphraseVisible: false, }; - case 'BACK_UP_SEEDPHRASE_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_VISIBLE: return { ...state, backUpSeedphraseVisible: true, }; - case 'BACK_UP_SEEDPHRASE_NOT_VISIBLE': + case UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE: return { ...state, backUpSeedphraseVisible: false, }; - case 'PROTECT_MODAL_VISIBLE': + case UserActionType.PROTECT_MODAL_VISIBLE: if (!state.seedphraseBackedUp) { return { ...state, @@ -114,17 +94,17 @@ const userReducer = ( }; } return state; - case 'PROTECT_MODAL_NOT_VISIBLE': + case UserActionType.PROTECT_MODAL_NOT_VISIBLE: return { ...state, protectWalletModalVisible: false, }; - case 'SET_GAS_EDUCATION_CAROUSEL_SEEN': + case UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN: return { ...state, gasEducationCarouselSeen: true, }; - case 'SET_APP_THEME': + case UserActionType.SET_APP_THEME: return { ...state, appTheme: action.payload.theme, diff --git a/app/reducers/user/selectors.ts b/app/reducers/user/selectors.ts new file mode 100644 index 00000000000..2341b19cad8 --- /dev/null +++ b/app/reducers/user/selectors.ts @@ -0,0 +1,6 @@ +import { RootState } from '..'; + +/** + * Selects the user state + */ +export const selectUserState = (state: RootState) => state.user; diff --git a/app/reducers/user/types.ts b/app/reducers/user/types.ts new file mode 100644 index 00000000000..4080aebc5ee --- /dev/null +++ b/app/reducers/user/types.ts @@ -0,0 +1,19 @@ +import { AppThemeKey } from '../../util/theme/models'; + +/** + * User state + */ +export interface UserState { + loadingMsg: string; + loadingSet: boolean; + passwordSet: boolean; + seedphraseBackedUp: boolean; + backUpSeedphraseVisible: boolean; + protectWalletModalVisible: boolean; + gasEducationCarouselSeen: boolean; + userLoggedIn: boolean; + isAuthChecked: boolean; + initialScreen: string; + appTheme: AppThemeKey; + ambiguousAddressEntries: Record; +} diff --git a/app/store/index.ts b/app/store/index.ts index 5f74eb552d9..e158b1e5f81 100644 --- a/app/store/index.ts +++ b/app/store/index.ts @@ -4,9 +4,6 @@ import { persistStore, persistReducer } from 'redux-persist'; import createSagaMiddleware from 'redux-saga'; import { rootSaga } from './sagas'; import rootReducer, { RootState } from '../reducers'; -import EngineService from '../core/EngineService'; -import { Authentication } from '../core'; -import LockManagerService from '../core/LockManagerService'; import ReadOnlyNetworkStore from '../util/test/network-store'; import { isE2E } from '../util/test/utils'; import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; @@ -14,8 +11,9 @@ import { trace, endTrace, TraceName, TraceOperation } from '../util/trace'; import thunk from 'redux-thunk'; import persistConfig from './persistConfig'; -import { AppStateEventProcessor } from '../core/AppStateEventListener'; import getUIStartupSpan from '../core/Performance/UIStartup'; +import ReduxService from '../core/redux'; +import { onPersistedDataLoaded } from '../actions/user'; // TODO: Improve type safety by using real Action types instead of `any` // TODO: Replace "any" with type @@ -59,6 +57,8 @@ const createStoreAndPersistor = async () => { middleware: middlewares, preloadedState: initialState, }); + // Set the store in the Redux class + ReduxService.store = store; sagaMiddleware.run(rootSaga); @@ -66,34 +66,9 @@ const createStoreAndPersistor = async () => { * Initialize services after persist is completed */ const onPersistComplete = () => { - /** - * EngineService.initalizeEngine(store) with SES/lockdown: - * Requires ethjs nested patches (lib->src) - * - ethjs/ethjs-query - * - ethjs/ethjs-contract - * Otherwise causing the following errors: - * - TypeError: Cannot assign to read only property 'constructor' of object '[object Object]' - * - Error: Requiring module "node_modules/ethjs/node_modules/ethjs-query/lib/index.js", which threw an exception: TypeError: - * - V8: Cannot assign to read only property 'constructor' of object '[object Object]' - * - JSC: Attempted to assign to readonly property - * - node_modules/babel-runtime/node_modules/regenerator-runtime/runtime.js - * - V8: TypeError: _$$_REQUIRE(...) is not a constructor - * - TypeError: undefined is not an object (evaluating 'TokenListController.tokenList') - * - V8: SES_UNHANDLED_REJECTION - */ - - store.dispatch({ - type: 'TOGGLE_BASIC_FUNCTIONALITY', - basicFunctionalityEnabled: - store.getState().settings.basicFunctionalityEnabled, - }); - - EngineService.initalizeEngine(store); - - Authentication.init(store); - AppStateEventProcessor.init(store); - LockManagerService.init(store); endTrace({ name: TraceName.StoreInit }); + // Signal that persisted data has been loaded + store.dispatch(onPersistedDataLoaded()); }; persistor = persistStore(store, null, onPersistComplete); diff --git a/app/store/persistConfig.ts b/app/store/persistConfig.ts index 0aa71c0ccad..5b8b4358134 100644 --- a/app/store/persistConfig.ts +++ b/app/store/persistConfig.ts @@ -6,7 +6,7 @@ import { RootState } from '../reducers'; import { migrations, version } from './migrations'; import Logger from '../util/Logger'; import Device from '../util/device'; -import { IUserReducer } from '../reducers/user'; +import { UserState } from '../reducers/user'; const TIMEOUT = 40000; @@ -96,7 +96,7 @@ const persistTransform = createTransform( ); const persistUserTransform = createTransform( - (inboundState: IUserReducer) => { + (inboundState: UserState) => { const { initialScreen, isAuthChecked, ...state } = inboundState; // Reconstruct data to persist return state; diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts index 32afb2b55c7..b7b0724a993 100644 --- a/app/store/sagas/index.ts +++ b/app/store/sagas/index.ts @@ -1,15 +1,14 @@ -import { fork, take, cancel, put, call } from 'redux-saga/effects'; +import { fork, take, cancel, put, call, all } from 'redux-saga/effects'; import NavigationService from '../../core/NavigationService'; import Routes from '../../constants/navigation/Routes'; import { - LOCKED_APP, - AUTH_SUCCESS, - AUTH_ERROR, + AuthSuccessAction, + AuthErrorAction, + InterruptBiometricsAction, lockApp, - INTERRUPT_BIOMETRICS, - LOGOUT, - LOGIN, + UserActionType, } from '../../actions/user'; +import { NavigationActionType } from '../../actions/navigation'; import { Task } from 'redux-saga'; import Engine from '../../core/Engine'; import Logger from '../../util/Logger'; @@ -18,11 +17,13 @@ import { overrideXMLHttpRequest, restoreXMLHttpRequest, } from './xmlHttpRequestOverride'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; export function* appLockStateMachine() { let biometricsListenerTask: Task | undefined; while (true) { - yield take(LOCKED_APP); + yield take(UserActionType.LOCKED_APP); if (biometricsListenerTask) { yield cancel(biometricsListenerTask); } @@ -45,11 +46,11 @@ export function* appLockStateMachine() { export function* authStateMachine() { // Start when the user is logged in. while (true) { - yield take(LOGIN); + yield take(UserActionType.LOGIN); const appLockStateMachineTask: Task = yield fork(appLockStateMachine); LockManagerService.startListening(); // Listen to app lock behavior. - yield take(LOGOUT); + yield take(UserActionType.LOGOUT); LockManagerService.stopListening(); // Cancels appLockStateMachineTask, which also cancels nested sagas once logged out. yield cancel(appLockStateMachineTask); @@ -78,32 +79,32 @@ export function* biometricsStateMachine(originalBioStateMachineId: string) { // Handle next three possible states. let shouldHandleAction = false; let action: - | { - type: - | typeof AUTH_SUCCESS - | typeof AUTH_ERROR - | typeof INTERRUPT_BIOMETRICS; - payload?: { bioStateMachineId: string }; - } + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction | undefined; // Only continue on INTERRUPT_BIOMETRICS action or when actions originated from corresponding state machine. while (!shouldHandleAction) { - action = yield take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]); + action = yield take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]); if ( - action?.type === INTERRUPT_BIOMETRICS || + action?.type === UserActionType.INTERRUPT_BIOMETRICS || action?.payload?.bioStateMachineId === originalBioStateMachineId ) { shouldHandleAction = true; } } - if (action?.type === INTERRUPT_BIOMETRICS) { + if (action?.type === UserActionType.INTERRUPT_BIOMETRICS) { // Biometrics was most likely interrupted during authentication with a non-zero lock timer. yield fork(lockKeyringAndApp); - } else if (action?.type === AUTH_ERROR) { + } else if (action?.type === UserActionType.AUTH_ERROR) { // Authentication service will automatically log out. - } else if (action?.type === AUTH_SUCCESS) { + } else if (action?.type === UserActionType.AUTH_SUCCESS) { // Authentication successful. Navigate to wallet. NavigationService.navigation?.navigate(Routes.ONBOARDING.HOME_NAV); } @@ -124,8 +125,24 @@ export function* basicFunctionalityToggle() { } } +/** + * Handles initializing app services on start up + */ +export function* startAppServices() { + // Wait for persisted data to be loaded and navigation to be ready + yield all([ + take(UserActionType.ON_PERSISTED_DATA_LOADED), + take(NavigationActionType.ON_NAVIGATION_READY), + ]); + // Start services + EngineService.start(); + AppStateEventProcessor.start(); + // TODO: Track a property in redux to gate keep the app until services are initialized +} + // Main generator function that initializes other sagas in parallel. export function* rootSaga() { + yield fork(startAppServices); yield fork(authStateMachine); yield fork(basicFunctionalityToggle); } diff --git a/app/store/sagas/sagas.test.ts b/app/store/sagas/sagas.test.ts index c9830d7f8d4..70ffa35e276 100644 --- a/app/store/sagas/sagas.test.ts +++ b/app/store/sagas/sagas.test.ts @@ -1,12 +1,8 @@ import { Action } from 'redux'; import { take, fork, cancel } from 'redux-saga/effects'; +import { expectSaga } from 'redux-saga-test-plan'; import { - AUTH_ERROR, - AUTH_SUCCESS, - INTERRUPT_BIOMETRICS, - LOGIN, - LOCKED_APP, - LOGOUT, + UserActionType, authError, authSuccess, interruptBiometrics, @@ -17,10 +13,16 @@ import { authStateMachine, appLockStateMachine, lockKeyringAndApp, + startAppServices, } from './'; +import { NavigationActionType } from '../../actions/navigation'; +import EngineService from '../../core/EngineService'; +import { AppStateEventProcessor } from '../../core/AppStateEventListener'; const mockBioStateMachineId = '123'; + const mockNavigate = jest.fn(); + jest.mock('../../core/NavigationService', () => ({ navigation: { // TODO: Replace "any" with type @@ -31,6 +33,17 @@ jest.mock('../../core/NavigationService', () => ({ }, })); +// Mock the services +jest.mock('../../core/EngineService', () => ({ + start: jest.fn(), +})); + +jest.mock('../../core/AppStateEventListener', () => ({ + AppStateEventProcessor: { + start: jest.fn(), + }, +})); + describe('authStateMachine', () => { beforeEach(() => { mockNavigate.mockClear(); @@ -38,7 +51,7 @@ describe('authStateMachine', () => { it('should fork appLockStateMachine when logged in', async () => { const generator = authStateMachine(); - expect(generator.next().value).toEqual(take(LOGIN)); + expect(generator.next().value).toEqual(take(UserActionType.LOGIN)); expect(generator.next().value).toEqual(fork(appLockStateMachine)); }); @@ -48,7 +61,7 @@ describe('authStateMachine', () => { generator.next(); // Fork appLockStateMachine generator.next(); - expect(generator.next().value).toEqual(take(LOGOUT)); + expect(generator.next().value).toEqual(take(UserActionType.LOGOUT)); expect(generator.next().value).toEqual(cancel()); }); }); @@ -60,7 +73,7 @@ describe('appLockStateMachine', () => { it('should fork biometricsStateMachine when app is locked', async () => { const generator = appLockStateMachine(); - expect(generator.next().value).toEqual(take(LOCKED_APP)); + expect(generator.next().value).toEqual(take(UserActionType.LOCKED_APP)); // Fork biometrics listener. expect(generator.next().value).toEqual( fork(biometricsStateMachine, mockBioStateMachineId), @@ -90,7 +103,11 @@ describe('biometricsStateMachine', () => { const generator = biometricsStateMachine(mockBioStateMachineId); // Take next step expect(generator.next().value).toEqual( - take([AUTH_SUCCESS, AUTH_ERROR, INTERRUPT_BIOMETRICS]), + take([ + UserActionType.AUTH_SUCCESS, + UserActionType.AUTH_ERROR, + UserActionType.INTERRUPT_BIOMETRICS, + ]), ); // Dispatch interrupt biometrics const nextFork = generator.next(interruptBiometrics() as Action).value; @@ -127,3 +144,44 @@ describe('biometricsStateMachine', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + +// TODO: Update all saga tests to use expectSaga (more intuitive and easier to read) +describe('startAppServices', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should start app services', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are started + expect(EngineService.start).toHaveBeenCalled(); + expect(AppStateEventProcessor.start).toHaveBeenCalled(); + }); + + it('should not start app services if navigation is not ready', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: UserActionType.ON_PERSISTED_DATA_LOADED }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); + + it('should not start app services if persisted data is not loaded', async () => { + await expectSaga(startAppServices) + // Dispatch both required actions + .dispatch({ type: NavigationActionType.ON_NAVIGATION_READY }) + .run(); + + // Verify services are not started + expect(EngineService.start).not.toHaveBeenCalled(); + expect(AppStateEventProcessor.start).not.toHaveBeenCalled(); + }); +}); diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index 20ed3cd2a88..d7e1c2b64da 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -7,6 +7,7 @@ import { initialState as transactionMetrics } from '../../core/redux/slices/tran import { initialState as originThrottling } from '../../core/redux/slices/originThrottling'; import initialBackgroundState from './initial-background-state.json'; import { userInitialState } from '../../reducers/user'; +import { initialNavigationState } from '../../reducers/navigation'; import { initialState as initialStakingState } from '../../core/redux/slices/staking'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) import { initialState as initialMultichainSettingsState } from '../../reducers/multichain'; @@ -35,7 +36,7 @@ const initialRootState: RootState = { swaps: undefined, fiatOrders: initialFiatOrdersState, infuraAvailability: undefined, - navigation: undefined, + navigation: initialNavigationState, networkOnboarded: undefined, security: initialSecurityState, signatureRequest: undefined, diff --git a/babel.config.js b/babel.config.js index af4c36a0a77..8b3aa390673 100644 --- a/babel.config.js +++ b/babel.config.js @@ -23,6 +23,12 @@ module.exports = { test: './app/lib/snaps', plugins: [['babel-plugin-inline-import', { extensions: ['.html'] }]], }, + // TODO: Remove this once we have a fix for the private methods + // Do not apply this plugin globally since it breaks FlatList props.getItem + { + test: './app/core/redux/ReduxService.ts', + plugins: [['@babel/plugin-transform-private-methods', { loose: true }]], + }, ], env: { production: { diff --git a/package.json b/package.json index f27eefc20d4..36e526e6b4d 100644 --- a/package.json +++ b/package.json @@ -496,6 +496,7 @@ "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.2.0", "redux-flipper": "^2.0.3", + "redux-saga-test-plan": "^4.0.6", "regenerator-runtime": "0.13.9", "rn-nodeify": "10.3.0", "serve-handler": "^6.1.5", diff --git a/yarn.lock b/yarn.lock index aa0795670d9..f8e9220e36c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17874,6 +17874,11 @@ fsevents@^2.3.2, fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +fsm-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fsm-iterator/-/fsm-iterator-1.1.0.tgz#337de45de19eb205788cf02e3a955ec206760dec" + integrity sha512-hg47CNYdIGJ5m9WSKh617LHRdvJo4PiF0VkncFLwPVxKvBEQfSPd1qx/xLV/eSusewEu0C8eUFrsLsWlBgIcOg== + ftp-response-parser@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz#3b9d33f8edd5fb8e4700b8f778c462e5b1581f89" @@ -21185,6 +21190,11 @@ lodash.isequal@4.5.0, lodash.isequal@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== +lodash.ismatch@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" + integrity sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g== + lodash.isobject@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" @@ -25607,6 +25617,15 @@ redux-persist@6.0.0: resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== +redux-saga-test-plan@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/redux-saga-test-plan/-/redux-saga-test-plan-4.0.6.tgz#0e50a68f63083fbda4bb20cc087833d5b84ace77" + integrity sha512-ESdbFoDWCeJ/EiFdUNSCGtA2CC9tnuvHDm6k06gVFa98EIeR2hpzFkGk9kJ1/hpMUnYFp+OOEEITIrZeDYBfFg== + dependencies: + fsm-iterator "^1.1.0" + lodash.isequal "^4.5.0" + lodash.ismatch "^4.4.0" + redux-saga@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.3.0.tgz#a59ada7c28010189355356b99738c9fcb7ade30e" From 9d63e3407607e32e7fadc2b33c2d776193d930a3 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Tue, 10 Dec 2024 12:19:07 +0100 Subject: [PATCH 05/31] chore: update user storage E2E framework (#12609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR updates user storage related E2E tests. - `userStorageMockttpController` now uses the `USER_STORAGE_FEATURE_NAMES` constant in order to define paths - `userStorageMockttpController` now supports batch deleting items - E2E tests now use `USER_STORAGE_FEATURE_NAMES` to define paths ## **Related issues** Fixes: ## **Manual testing steps** 1. No testing steps, user storage E2E tests are disabled for now ## **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. --- ...c-after-adding-custom-name-account.spec.js | 11 +- .../sync-after-onboarding.spec.js | 11 +- e2e/specs/identity/utils/mocks.js | 11 +- .../userStorageMockttpController.js | 111 ++++--- .../userStorageMockttpController.test.js | 274 +++++++++++++----- e2e/specs/notifications/utils/mocks.js | 6 +- 6 files changed, 300 insertions(+), 124 deletions(-) diff --git a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js index a586d49349a..98931feee82 100644 --- a/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-adding-custom-name-account.spec.js @@ -18,6 +18,7 @@ import AddAccountBottomSheet from '../../../pages/wallet/AddAccountBottomSheet'; import AccountActionsBottomSheet from '../../../pages/wallet/AccountActionsBottomSheet'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { const NEW_ACCOUNT_NAME = 'My third account'; @@ -33,9 +34,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); decryptedAccountNames = await Promise.all( accountsSyncMockResponse.map(async (response) => { diff --git a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js index 83fdd49c635..4930a9cf1be 100644 --- a/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js +++ b/e2e/specs/identity/account-syncing/sync-after-onboarding.spec.js @@ -16,6 +16,7 @@ import AccountListBottomSheet from '../../../pages/wallet/AccountListBottomSheet import Assertions from '../../../utils/Assertions'; import { mockIdentityServices } from '../utils/mocks'; import { SmokeIdentity } from '../../../tags'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe(SmokeIdentity('Account syncing'), () => { beforeAll(async () => { @@ -27,9 +28,13 @@ describe(SmokeIdentity('Account syncing'), () => { mockServer, ); - userStorageMockttpControllerInstance.setupPath('accounts', mockServer, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: accountsSyncMockResponse, + }, + ); jest.setTimeout(200000); await TestHelpers.reverseServerPort(); diff --git a/e2e/specs/identity/utils/mocks.js b/e2e/specs/identity/utils/mocks.js index a8509c988e0..f8203ffb69c 100644 --- a/e2e/specs/identity/utils/mocks.js +++ b/e2e/specs/identity/utils/mocks.js @@ -1,6 +1,7 @@ import { AuthenticationController } from '@metamask/profile-sync-controller'; import { UserStorageMockttpController } from './user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const AuthMocks = AuthenticationController.Mocks; @@ -20,8 +21,14 @@ export async function mockIdentityServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('accounts', server); - userStorageMockttpControllerInstance.setupPath('networks', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.networks, + server, + ); return { userStorageMockttpControllerInstance, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js index 9438e2a7fc7..8ffafe83d34 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.js @@ -1,16 +1,25 @@ +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { determineIfFeatureEntryFromURL, getDecodedProxiedURL, } from '../helpers'; -// TODO: Export user storage schema from @metamask/profile-sync-controller +const baseUrl = + 'https://user-storage\\.api\\.cx\\.metamask\\.io\\/api\\/v1\\/userstorage'; + export const pathRegexps = { - accounts: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/accounts/u, - networks: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/networks/u, - notifications: - /https:\/\/user-storage\.api\.cx\.metamask\.io\/api\/v1\/userstorage\/notifications/u, + [USER_STORAGE_FEATURE_NAMES.accounts]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.networks]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.networks}`, + 'u', + ), + [USER_STORAGE_FEATURE_NAMES.notifications]: new RegExp( + `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.notifications}`, + 'u', + ), }; export class UserStorageMockttpController { @@ -57,44 +66,68 @@ export class UserStorageMockttpController { const data = await request.body.getJson(); - const newOrUpdatedSingleOrBatchEntries = - isFeatureEntry && typeof data?.data === 'string' - ? [ - { - HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), - Data: data?.data, - }, - ] - : Object.entries(data?.data).map(([key, value]) => ({ - HashedKey: key, - Data: value, - })); - - newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + // We're handling batch delete inside the PUT method due to API limitations + if (data?.batch_delete) { + const keysToDelete = data.batch_delete; + const internalPathData = this.paths.get(path); if (!internalPathData) { - return; + return { + statusCode, + }; } - const doesThisEntryExist = internalPathData.response?.find( - (existingEntry) => existingEntry.HashedKey === entry.HashedKey, - ); + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.filter( + (entry) => !keysToDelete.includes(entry.HashedKey), + ), + }); + } - if (doesThisEntryExist) { - this.paths.set(path, { - ...internalPathData, - response: internalPathData.response.map((existingEntry) => - existingEntry.HashedKey === entry.HashedKey ? entry : existingEntry, - ), - }); - } else { - this.paths.set(path, { - ...internalPathData, - response: [...(internalPathData?.response || []), entry], - }); - } - }); + if (data?.data) { + const newOrUpdatedSingleOrBatchEntries = + isFeatureEntry && typeof data?.data === 'string' + ? [ + { + HashedKey: getDecodedProxiedURL(request.url).split('/').pop(), + Data: data?.data, + }, + ] + : Object.entries(data?.data).map(([key, value]) => ({ + HashedKey: key, + Data: value, + })); + + newOrUpdatedSingleOrBatchEntries.forEach((entry) => { + const internalPathData = this.paths.get(path); + + if (!internalPathData) { + return; + } + + const doesThisEntryExist = internalPathData.response?.find( + (existingEntry) => existingEntry.HashedKey === entry.HashedKey, + ); + + if (doesThisEntryExist) { + this.paths.set(path, { + ...internalPathData, + response: internalPathData.response.map((existingEntry) => + existingEntry.HashedKey === entry.HashedKey + ? entry + : existingEntry, + ), + }); + } else { + this.paths.set(path, { + ...internalPathData, + response: [...(internalPathData?.response || []), entry], + }); + } + }); + } return { statusCode, diff --git a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js index 5ea0f1603ca..dc7ee91cfa3 100644 --- a/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js +++ b/e2e/specs/identity/utils/user-storage/userStorageMockttpController.test.js @@ -1,5 +1,6 @@ import { getLocal } from 'mockttp'; import { UserStorageMockttpController } from './userStorageMockttpController'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; describe('UserStorageMockttpController', () => { let mockServer; @@ -13,11 +14,17 @@ describe('UserStorageMockttpController', () => { it('handles GET requests that have empty response', async () => { const controller = new UserStorageMockttpController(); - await controller.setupPath('accounts', mockServer); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(null); }); @@ -37,13 +44,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -63,13 +77,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -89,13 +110,20 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const request = await controller.onGet('accounts', { - url: `${baseUrl}/accounts/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, + }, + ); expect(request.json).toEqual(mockedData[0]); }); @@ -120,24 +148,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, - body: { - getJson: async () => ({ - data: mockedAddedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, + body: { + getJson: async () => ({ + data: mockedAddedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([...mockedData, mockedAddedData]); }); @@ -162,24 +200,34 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - body: { - getJson: async () => ({ - data: mockedUpdatedData.Data, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + body: { + getJson: async () => ({ + data: mockedUpdatedData.Data, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0], mockedUpdatedData]); }); @@ -211,29 +259,39 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); const putData = {}; mockedUpdatedData.forEach((entry) => { putData[entry.HashedKey] = entry.Data; }); - const putRequest = await controller.onPut('accounts', { - url: `${baseUrl}/accounts`, - body: { - getJson: async () => ({ - data: putData, - }), + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + data: putData, + }), + }, }, - }); + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual(mockedUpdatedData); }); @@ -253,19 +311,29 @@ describe('UserStorageMockttpController', () => { }, ]; - await controller.setupPath('accounts', mockServer, { - getResponse: mockedData, - }); + await controller.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + mockServer, + { + getResponse: mockedData, + }, + ); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + url: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([mockedData[0]]); }); @@ -283,22 +351,76 @@ describe('UserStorageMockttpController', () => { 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', Data: 'data2', }, + { + HashedKey: + 'x236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data3', + }, ]; - await controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - url: `${baseUrl}/accounts`, - }); + const deleteRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + batch_delete: [mockedData[1].HashedKey, mockedData[2].HashedKey], + }), + }, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - url: `${baseUrl}/accounts`, + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(getRequest.json).toEqual([mockedData[0]]); + }); + + it('handles entire feature DELETE requests', async () => { + const controller = new UserStorageMockttpController(); + const mockedData = [ + { + HashedKey: + '7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b', + Data: 'data1', + }, + { + HashedKey: + 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data2', + }, + ]; + + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { + getResponse: mockedData, }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + + expect(deleteRequest.statusCode).toEqual(204); + + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); + expect(getRequest.json).toEqual(null); }); }); diff --git a/e2e/specs/notifications/utils/mocks.js b/e2e/specs/notifications/utils/mocks.js index dbf8e8b5e73..72db127ca3a 100644 --- a/e2e/specs/notifications/utils/mocks.js +++ b/e2e/specs/notifications/utils/mocks.js @@ -4,6 +4,7 @@ import { } from '@metamask/notification-services-controller'; import { UserStorageMockttpController } from '../../identity/utils/user-storage/userStorageMockttpController'; import { getDecodedProxiedURL } from './helpers'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; const NotificationMocks = NotificationServicesController.Mocks; const PushMocks = NotificationServicesPushController.Mocks; @@ -19,7 +20,10 @@ export async function mockNotificationServices(server) { const userStorageMockttpControllerInstance = new UserStorageMockttpController(); - userStorageMockttpControllerInstance.setupPath('notifications', server); + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.notifications, + server, + ); // Notifications mockAPICall(server, NotificationMocks.getMockFeatureAnnouncementResponse()); 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 06/31] 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 07/31] 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 08/31] 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 09/31] 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 10/31] 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 11/31] 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 12/31] 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 13/31] 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 14/31] 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 15/31] 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 16/31] 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 17/31] 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 18/31] 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 = ({