diff --git a/.circleci/config.yml b/.circleci/config.yml index aa13dea93b75..a8185c00ee2f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -197,6 +197,7 @@ workflows: requires: - prep-deps - test-e2e-chrome-webpack: + <<: *develop_master_rc_only requires: - prep-build-test-webpack - get-changed-files-with-git-diff @@ -205,6 +206,7 @@ workflows: - prep-build-test - get-changed-files-with-git-diff - test-e2e-firefox: + <<: *develop_master_rc_only requires: - prep-build-test-mv2 - get-changed-files-with-git-diff @@ -234,6 +236,9 @@ workflows: - test-e2e-mmi-playwright: requires: - prep-build-test-mmi-playwright + - test-e2e-swap-playwright - OPTIONAL: + requires: + - prep-build - test-e2e-chrome-rpc-mmi: requires: - prep-build-test-mmi @@ -959,7 +964,7 @@ jobs: at: . - run: name: Install Playwright browsers - command: yarn exec playwright install + command: yarn exec playwright install chromium - run: name: Test Storybook command: yarn test-storybook:ci @@ -1192,7 +1197,7 @@ jobs: at: . - run: name: test:e2e:single - command: .circleci/scripts/test-run-e2e.sh yarn test:e2e:single test/e2e/vault-decryption-chrome.spec.js --browser chrome + command: .circleci/scripts/test-run-e2e.sh yarn test:e2e:single test/e2e/vault-decryption-chrome.spec.ts --browser chrome no_output_timeout: 5m - store_artifacts: path: test-artifacts diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 50b79a78ec30..5260be984692 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -70,7 +70,6 @@ ignores: - 'crypto-browserify' # polyfill - 'process' # polyfill - 'stream-http' # polyfill - - 'rimraf' # misc: install helper - 'json-schema-to-ts' # misc: typescript helper - 'https-browserify' # polyfill - 'path-browserify' # polyfill diff --git a/.storybook/test-data.js b/.storybook/test-data.js index 13006e5d1ff7..a36cbf944981 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -487,6 +487,32 @@ const state = { }, }, }, + allTokens: { + '0x1': { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', + aggregators: [], + decimals: 18, + symbol: 'YFI', + }, + ], + }, + }, + tokenBalances: { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xbdbd', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501b4176a64d6', + }, + }, + }, tokens: [ { address: '0xaD6D458402F60fD3Bd25163575031ACDce07538A', @@ -682,6 +708,7 @@ const state = { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, }, incomingTransactionsPreferences: { [CHAIN_IDS.MAINNET]: true, diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 51c7db9f6211..0696498afe86 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -38,6 +38,7 @@ "codespace", "codespaces", "corepack", + "crossorigin", "datetime", "datetimes", "dedupe", diff --git a/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch b/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch new file mode 100644 index 000000000000..15335a88e494 --- /dev/null +++ b/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch @@ -0,0 +1,15 @@ +diff --git a/lib/message/EIP712/index.js b/lib/message/EIP712/index.js +index 5443ac06d4ef29028e3acce98210949da2ef9263..57ec8615340a9285290ad5d2c2522ed19af2d0fc 100644 +--- a/lib/message/EIP712/index.js ++++ b/lib/message/EIP712/index.js +@@ -28,8 +28,8 @@ const ethers_1 = require("ethers"); + const axios_1 = __importDefault(require("axios")); + const sha224_1 = __importDefault(require("crypto-js/sha224")); + const live_env_1 = require("@ledgerhq/live-env"); +-const eip712_1 = __importDefault(require("@ledgerhq/cryptoassets-evm-signatures/data/eip712")); +-const eip712_v2_1 = __importDefault(require("@ledgerhq/cryptoassets-evm-signatures/data/eip712_v2")); ++const eip712_1 = __importDefault(require("@ledgerhq/cryptoassets-evm-signatures/lib/data/eip712")); ++const eip712_v2_1 = __importDefault(require("@ledgerhq/cryptoassets-evm-signatures/lib/data/eip712_v2")); + // As defined in [spec](https://eips.ethereum.org/EIPS/eip-712), the properties below are all required. + function isEIP712Message(message) { + return (!!message && diff --git a/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch b/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch new file mode 100644 index 000000000000..cfefcca767a7 --- /dev/null +++ b/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch @@ -0,0 +1,65 @@ +diff --git a/lib/modules/EIP712/index.js b/lib/modules/EIP712/index.js +index bbca23c9a596b2b300aca0f323bad277a4190def..c85cb18c3d6ff049c442d358b4d834c04a49951f 100644 +--- a/lib/modules/EIP712/index.js ++++ b/lib/modules/EIP712/index.js +@@ -15,7 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); + exports.signEIP712HashedMessage = exports.signEIP712Message = void 0; + /* eslint-disable @typescript-eslint/no-duplicate-enum-values */ + const semver_1 = __importDefault(require("semver")); +-const index_1 = require("@ledgerhq/evm-tools/message/EIP712/index"); ++const index_1 = require("@ledgerhq/evm-tools/lib/message/EIP712/index"); + const erc20_1 = require("../../services/ledger/erc20"); + const utils_1 = require("../../utils"); + const loadConfig_1 = require("../../services/ledger/loadConfig"); +diff --git a/lib/modules/EIP712/utils.js b/lib/modules/EIP712/utils.js +index 4f09ea8a24ae4f175f1a4d2d40999d36ffa2a915..1cc7de0d5ffd2b980741ad6d85a75792085c031b 100644 +--- a/lib/modules/EIP712/utils.js ++++ b/lib/modules/EIP712/utils.js +@@ -14,7 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { + Object.defineProperty(exports, "__esModule", { value: true }); + exports.getPayloadForFilterV2 = exports.getFilterDisplayNameAndSigBuffers = exports.getAppAndVersion = exports.getCoinRefTokensMap = exports.makeTypeEntryStructBuffer = exports.constructTypeDescByteString = exports.destructTypeFromString = exports.EIP712_TYPE_ENCODERS = exports.EIP712_TYPE_PROPERTIES = void 0; + const bignumber_js_1 = __importDefault(require("bignumber.js")); +-const index_1 = require("@ledgerhq/evm-tools/message/index"); ++const index_1 = require("@ledgerhq/evm-tools/lib/message/index"); + const utils_1 = require("../../utils"); + /** + * @ignore for the README +diff --git a/lib/services/ledger/erc20.js b/lib/services/ledger/erc20.js +index 8fdedf8037b2684ab6d48fd279a0c014b074b676..c10cea22504a4d3cd72bfac51463e5664639d20e 100644 +--- a/lib/services/ledger/erc20.js ++++ b/lib/services/ledger/erc20.js +@@ -15,7 +15,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); + exports.byContractAddressAndChainId = exports.findERC20SignaturesInfo = void 0; + const axios_1 = __importDefault(require("axios")); + const logs_1 = require("@ledgerhq/logs"); +-const index_1 = require("@ledgerhq/cryptoassets-evm-signatures/data/evm/index"); ++const index_1 = require("@ledgerhq/cryptoassets-evm-signatures/lib/data/evm/index"); + const loadConfig_1 = require("./loadConfig"); + const asContractAddress = (addr) => { + const a = addr.toLowerCase(); +diff --git a/lib/services/ledger/index.js b/lib/services/ledger/index.js +index 8385537236dfc98902376b29f712e9f7b605091a..6641b951b0c7e477fe5622c724ee22b9e5272213 100644 +--- a/lib/services/ledger/index.js ++++ b/lib/services/ledger/index.js +@@ -12,7 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); + // This implements the resolution of a Transaction using Ledger's own API + const logs_1 = require("@ledgerhq/logs"); + const abi_1 = require("@ethersproject/abi"); +-const index_1 = require("@ledgerhq/domain-service/signers/index"); ++const index_1 = require("@ledgerhq/domain-service/lib/signers/index"); + const erc20_1 = require("./erc20"); + const contracts_1 = require("./contracts"); + const nfts_1 = require("./nfts"); +diff --git a/lib/utils.js b/lib/utils.js +index 027089bf09c047b34af79e3dea253d5335d336f3..1c5da628db47a33099532973de9ea634a1ee65df 100644 +--- a/lib/utils.js ++++ b/lib/utils.js +@@ -2,7 +2,7 @@ + Object.defineProperty(exports, "__esModule", { value: true }); + exports.mergeResolutions = exports.nftSelectors = exports.tokenSelectors = exports.intAsHexBytes = exports.decodeTxInfo = exports.maybeHexBuffer = exports.hexBuffer = exports.splitPath = exports.padHexString = exports.ERC1155_CLEAR_SIGNED_SELECTORS = exports.ERC721_CLEAR_SIGNED_SELECTORS = exports.ERC20_CLEAR_SIGNED_SELECTORS = void 0; + const bignumber_js_1 = require("bignumber.js"); +-const index_1 = require("@ledgerhq/evm-tools/selectors/index"); ++const index_1 = require("@ledgerhq/evm-tools/lib/selectors/index"); + Object.defineProperty(exports, "ERC20_CLEAR_SIGNED_SELECTORS", { enumerable: true, get: function () { return index_1.ERC20_CLEAR_SIGNED_SELECTORS; } }); + Object.defineProperty(exports, "ERC721_CLEAR_SIGNED_SELECTORS", { enumerable: true, get: function () { return index_1.ERC721_CLEAR_SIGNED_SELECTORS; } }); + Object.defineProperty(exports, "ERC1155_CLEAR_SIGNED_SELECTORS", { enumerable: true, get: function () { return index_1.ERC1155_CLEAR_SIGNED_SELECTORS; } }); diff --git a/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch b/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch similarity index 84% rename from .yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch rename to .yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch index 7a5837cd4818..2a6310c2db69 100644 --- a/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch +++ b/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch @@ -1,5 +1,5 @@ diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs -index e90a1b6767bc8ac54b7a4d580035cf5db6861dca..a5e0f03d2541b4e3540431ef2e6e4b60fb7ae9fe 100644 +index c2e83cf6caee19152aa164f1333cfef7b681e900..590b6de6e9d20ca402b82ac56b0929ab8c16c932 100644 --- a/dist/assetsUtil.cjs +++ b/dist/assetsUtil.cjs @@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { @@ -7,10 +7,10 @@ index e90a1b6767bc8ac54b7a4d580035cf5db6861dca..a5e0f03d2541b4e3540431ef2e6e4b60 }; Object.defineProperty(exports, "__esModule", { value: true }); +function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } - exports.fetchTokenContractExchangeRates = exports.reduceInBatchesSerially = exports.divideIntoBatches = exports.ethersBigNumberToBN = exports.addUrlProtocolPrefix = exports.getFormattedIpfsUrl = exports.getIpfsCIDv1AndPath = exports.removeIpfsProtocolPrefix = exports.isTokenListSupportedForNetwork = exports.isTokenDetectionSupportedForNetwork = exports.SupportedTokenDetectionNetworks = exports.formatIconUrlWithProxy = exports.formatAggregatorNames = exports.hasNewCollectionFields = exports.compareNftMetadata = exports.TOKEN_PRICES_BATCH_SIZE = void 0; + exports.fetchTokenContractExchangeRates = exports.reduceInBatchesSerially = exports.divideIntoBatches = exports.ethersBigNumberToBN = exports.addUrlProtocolPrefix = exports.getFormattedIpfsUrl = exports.getIpfsCIDv1AndPath = exports.removeIpfsProtocolPrefix = exports.isTokenListSupportedForNetwork = exports.isTokenDetectionSupportedForNetwork = exports.SupportedStakedBalanceNetworks = exports.SupportedTokenDetectionNetworks = exports.formatIconUrlWithProxy = exports.formatAggregatorNames = exports.hasNewCollectionFields = exports.compareNftMetadata = exports.TOKEN_PRICES_BATCH_SIZE = void 0; const controller_utils_1 = require("@metamask/controller-utils"); const utils_1 = require("@metamask/utils"); -@@ -221,7 +222,7 @@ async function getIpfsCIDv1AndPath(ipfsUrl) { +@@ -233,7 +234,7 @@ async function getIpfsCIDv1AndPath(ipfsUrl) { const index = url.indexOf('/'); const cid = index !== -1 ? url.substring(0, index) : url; const path = index !== -1 ? url.substring(index) : undefined; diff --git a/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch b/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch new file mode 100644 index 000000000000..1b9e5a4ba848 --- /dev/null +++ b/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch @@ -0,0 +1,62 @@ +diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs +index ab23c95d667357db365f925c4c4acce4736797f8..8fd5efde7a3c24080f8a43f79d10300e8c271245 100644 +--- a/dist/TokenDetectionController.cjs ++++ b/dist/TokenDetectionController.cjs +@@ -204,13 +204,10 @@ class TokenDetectionController extends (0, polling_controller_1.StaticIntervalPo + // Try detecting tokens via Account API first if conditions allow + if (supportedNetworks && chainsToDetectUsingAccountAPI.length > 0) { + const apiResult = await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_attemptAccountAPIDetection).call(this, chainsToDetectUsingAccountAPI, addressToDetect, supportedNetworks); +- // If API succeeds and no chains are left for RPC detection, we can return early +- if (apiResult?.result === 'success' && +- chainsToDetectUsingRpc.length === 0) { +- return; ++ // If the account API call failed, have those chains fall back to RPC detection ++ if (apiResult?.result === 'failed') { ++ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); + } +- // If API fails or chainsToDetectUsingRpc still has items, add chains to RPC detection +- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); + } + // Proceed with RPC detection if there are chains remaining in chainsToDetectUsingRpc + if (chainsToDetectUsingRpc.length > 0) { +@@ -446,8 +443,7 @@ async function _TokenDetectionController_addDetectedTokensViaAPI({ selectedAddre + const tokenBalancesByChain = await __classPrivateFieldGet(this, _TokenDetectionController_accountsAPI, "f") + .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) + .catch(() => null); +- if (!tokenBalancesByChain || +- Object.keys(tokenBalancesByChain).length === 0) { ++ if (tokenBalancesByChain === null) { + return { result: 'failed' }; + } + // Process each chain ID individually +diff --git a/dist/TokenDetectionController.mjs b/dist/TokenDetectionController.mjs +index f75eb5c2c74f2a9d15a79760985111171dc938e1..ebc30bb915cc39dabf49f9e0da84a7948ae1ed48 100644 +--- a/dist/TokenDetectionController.mjs ++++ b/dist/TokenDetectionController.mjs +@@ -205,13 +205,10 @@ export class TokenDetectionController extends StaticIntervalPollingController() + // Try detecting tokens via Account API first if conditions allow + if (supportedNetworks && chainsToDetectUsingAccountAPI.length > 0) { + const apiResult = await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_attemptAccountAPIDetection).call(this, chainsToDetectUsingAccountAPI, addressToDetect, supportedNetworks); +- // If API succeeds and no chains are left for RPC detection, we can return early +- if (apiResult?.result === 'success' && +- chainsToDetectUsingRpc.length === 0) { +- return; ++ // If the account API call failed, have those chains fall back to RPC detection ++ if (apiResult?.result === 'failed') { ++ __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); + } +- // If API fails or chainsToDetectUsingRpc still has items, add chains to RPC detection +- __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_addChainsToRpcDetection).call(this, chainsToDetectUsingRpc, chainsToDetectUsingAccountAPI, clientNetworks); + } + // Proceed with RPC detection if there are chains remaining in chainsToDetectUsingRpc + if (chainsToDetectUsingRpc.length > 0) { +@@ -446,8 +443,7 @@ async function _TokenDetectionController_addDetectedTokensViaAPI({ selectedAddre + const tokenBalancesByChain = await __classPrivateFieldGet(this, _TokenDetectionController_accountsAPI, "f") + .getMultiNetworksBalances(selectedAddress, chainIds, supportedNetworks) + .catch(() => null); +- if (!tokenBalancesByChain || +- Object.keys(tokenBalancesByChain).length === 0) { ++ if (tokenBalancesByChain === null) { + return { result: 'failed' }; + } + // Process each chain ID individually diff --git a/CHANGELOG.md b/CHANGELOG.md index af63a4ed61d3..e849728f7781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.7.0] +### Added +- Added Token Network Filter UI, allowing users to filter tokens by network (behind a feature flag) ([#27884](https://github.com/MetaMask/metamask-extension/pull/27884)) +- Added Ape token icon for mainnet ([#27974](https://github.com/MetaMask/metamask-extension/pull/27974)) +- Implemented redesigned native asset transfer for both wallet-initiated and dApp-initiated confirmations ([#27979](https://github.com/MetaMask/metamask-extension/pull/27979)) +- Enabled the Security Alerts API with a fallback mechanism to ensure user experience is not disrupted ([#28040](https://github.com/MetaMask/metamask-extension/pull/28040)) +- Added re-simulation logic to the transaction controller ([#28104](https://github.com/MetaMask/metamask-extension/pull/28104)) +- Made the message section in the signature page collapsible and added a copy option ([#28038](https://github.com/MetaMask/metamask-extension/pull/28038)) +- Added token transfer confirmation for ERC721 and ERC1155 tokens ([#27955](https://github.com/MetaMask/metamask-extension/pull/27955)) +- Added support for external links in feature announcements ([#26491](https://github.com/MetaMask/metamask-extension/pull/26491)) +- Added a Notifications option to the settings page ([#26843](https://github.com/MetaMask/metamask-extension/pull/26843)) +- Enabled the use of a preview token to view unpublished content from Contentful ([#27809](https://github.com/MetaMask/metamask-extension/pull/27809)) +- Added account syncing to MetaMask, allowing users to synchronize accounts and account names across devices ([#28120](https://github.com/MetaMask/metamask-extension/pull/28120)) +- Introduced a new phishing warning UI with improved design ([#27942](https://github.com/MetaMask/metamask-extension/pull/27942)) +- Added a privacy mode toggle to hide and show sensitive information and token balances ([#28021](https://github.com/MetaMask/metamask-extension/pull/28021)) +- Added test network to the default selected networks list if it is the globally selected network during a connection request ([#27980](https://github.com/MetaMask/metamask-extension/pull/27980)) + +### Changed +- Allowed users to remove Linea from the networks list and added it to the Popular Networks section ([#27512](https://github.com/MetaMask/metamask-extension/pull/27512)) +- Updated transaction controller to reduce gas limit fallback and remove global network usage from transaction simulation ([#27954](https://github.com/MetaMask/metamask-extension/pull/27954)) +- Reduced usage of scientific notation by implementing a decimals rounding strategy and added tooltips for full values ([#27992](https://github.com/MetaMask/metamask-extension/pull/27992)) +- Improved visibility of decrypted messages and added a "scroll to bottom" button ([#27622](https://github.com/MetaMask/metamask-extension/pull/27622)) +- Updated network message to show the full network name on the Review Permission and Connections pages ([#28126](https://github.com/MetaMask/metamask-extension/pull/28126)) +- Removed the feature flag for the confirmations screen ([#27877](https://github.com/MetaMask/metamask-extension/pull/27877)) + +### Fixed +- Fixed issue where token balance showed as 0 during send flow when navigating from the token details page ([#28136](https://github.com/MetaMask/metamask-extension/pull/28136)) +- Fixed issue where small spending caps were coerced to zero on the approve screen ([#28179](https://github.com/MetaMask/metamask-extension/pull/28179)) +- Fixed gas calculations for low Max base fee and Priority fee ([#28037](https://github.com/MetaMask/metamask-extension/pull/28037)) +- Disabled notifications when Basic functionality is turned off ([#28045]) +- Fixed alignment issues of custom UI links in Snaps ([#27957](https://github.com/MetaMask/metamask-extension/pull/27957)) +- Fixed misalignment of the quote rate in swaps ([#28016](https://github.com/MetaMask/metamask-extension/pull/28016)) +- Prevented scrolling to the account list item on the send page to keep the relevant UI in view ([#27934](https://github.com/MetaMask/metamask-extension/pull/27934)) +- Improved handling of network switching and adding networks to prevent issues with queued transactions ([#28090](https://github.com/MetaMask/metamask-extension/pull/28090)) +- Prevented redirect after adding a network in Onboarding Settings ([#28165](https://github.com/MetaMask/metamask-extension/pull/28165)) + +## [12.6.1] +### Fixed +- Fixed gas limit estimation on Base and BNB chains ([#28327](https://github.com/MetaMask/metamask-extension/pull/28327)) + ## [12.6.0] ### Added - Added the APE network icon ([#27841](https://github.com/MetaMask/metamask-extension/pull/27841)) @@ -5303,7 +5343,9 @@ Update styles and spacing on the critical error page ([#20350](https://github.c - Added the ability to restore accounts from seed words. -[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v12.7.0...HEAD +[12.7.0]: https://github.com/MetaMask/metamask-extension/compare/v12.6.1...v12.7.0 +[12.6.1]: https://github.com/MetaMask/metamask-extension/compare/v12.6.0...v12.6.1 [12.6.0]: https://github.com/MetaMask/metamask-extension/compare/v12.5.1...v12.6.0 [12.5.1]: https://github.com/MetaMask/metamask-extension/compare/v12.5.0...v12.5.1 [12.5.0]: https://github.com/MetaMask/metamask-extension/compare/v12.4.2...v12.5.0 diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 610ddba3a4dd..624c7e2b163b 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -847,6 +847,10 @@ "bridge": { "message": "Bridge" }, + "bridgeApproval": { + "message": "Approve $1 for bridge", + "description": "Used in the transaction display list to describe a transaction that is an approve call on a token that is to be bridged. $1 is the symbol of a token that has been approved." + }, "bridgeCalculatingAmount": { "message": "Calculating..." }, @@ -1343,6 +1347,10 @@ "creatorAddress": { "message": "Creator address" }, + "crossChainAggregatedBalancePopover": { + "message": "This reflects the value of all tokens you own on all networks. If you prefer seeing this value in ETH or other currencies, go to $1.", + "description": "$1 represents the settings page" + }, "crossChainSwapsLink": { "message": "Swap across networks with MetaMask Portfolio" }, @@ -1730,6 +1738,12 @@ "dropped": { "message": "Dropped" }, + "duplicateContactTooltip": { + "message": "This contact name collides with an existing account or contact" + }, + "duplicateContactWarning": { + "message": "You have duplicate contacts" + }, "edit": { "message": "Edit" }, @@ -1816,6 +1830,9 @@ "editGasTooLow": { "message": "Unknown processing time" }, + "editInPortfolio": { + "message": "Edit in Portfolio" + }, "editNetworkLink": { "message": "edit the original network" }, @@ -3024,6 +3041,9 @@ "message": "Address", "description": "Label above address field in name component modal." }, + "nameAlreadyInUse": { + "message": "Name is already in use" + }, "nameInstructionsNew": { "message": "If you know this address, give it a nickname to recognize it in the future.", "description": "Instruction text in name component modal when value is not recognised." @@ -4132,6 +4152,24 @@ "permissionsPageTourTitle": { "message": "Connected sites are now permissions" }, + "permitSimulationChange_approve": { + "message": "Spending cap" + }, + "permitSimulationChange_bidding": { + "message": "You bid" + }, + "permitSimulationChange_listing": { + "message": "You list" + }, + "permitSimulationChange_receive": { + "message": "You receive" + }, + "permitSimulationChange_revoke": { + "message": "Spending cap" + }, + "permitSimulationChange_transfer": { + "message": "You send" + }, "permitSimulationDetailInfo": { "message": "You're giving the spender permission to spend this many tokens from your account." }, @@ -5347,6 +5385,9 @@ "spendingCapTooltipDesc": { "message": "This is the amount of tokens the spender will be able to access on your behalf." }, + "spendingCaps": { + "message": "Spending caps" + }, "srpInputNumberOfWords": { "message": "I have a $1-word phrase", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." diff --git a/app/scripts/constants/sentry-state.ts b/app/scripts/constants/sentry-state.ts index 655851590441..d0fbe7bcb085 100644 --- a/app/scripts/constants/sentry-state.ts +++ b/app/scripts/constants/sentry-state.ts @@ -119,6 +119,7 @@ export const SENTRY_BACKGROUND_STATE = { quotes: [], quotesLastFetched: true, quotesLoadingStatus: true, + quotesRefreshCount: true, }, }, CronjobController: { @@ -244,6 +245,7 @@ export const SENTRY_BACKGROUND_STATE = { showFiatInTestnets: true, showTestNetworks: true, smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, showNativeTokenAsMainBalance: true, petnamesEnabled: true, showConfirmationAdvancedDetails: true, @@ -354,6 +356,9 @@ export const SENTRY_BACKGROUND_STATE = { [AllProperties]: false, }, }, + TokenBalancesController: { + tokenBalances: false, + }, TokenRatesController: { marketData: false, }, @@ -385,6 +390,8 @@ export const SENTRY_BACKGROUND_STATE = { UserStorageController: { isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, }, ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) ...MMI_SENTRY_BACKGROUND_STATE, diff --git a/app/scripts/controllers/app-state-controller.test.ts b/app/scripts/controllers/app-state-controller.test.ts index 740c4a7d33f8..4bc1cb63e390 100644 --- a/app/scripts/controllers/app-state-controller.test.ts +++ b/app/scripts/controllers/app-state-controller.test.ts @@ -5,6 +5,7 @@ import { ORIGIN_METAMASK, POLLING_TOKEN_ENVIRONMENT_TYPES, } from '../../../shared/constants/app'; +import { AccountOverviewTabKey } from '../../../shared/constants/app-state'; import { AppStateController } from './app-state-controller'; import type { AllowedActions, @@ -209,9 +210,11 @@ describe('AppStateController', () => { describe('setDefaultHomeActiveTabName', () => { it('sets the default home tab name', () => { - appStateController.setDefaultHomeActiveTabName('testTabName'); + appStateController.setDefaultHomeActiveTabName( + AccountOverviewTabKey.Activity, + ); expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( - 'testTabName', + AccountOverviewTabKey.Activity, ); }); }); diff --git a/app/scripts/controllers/app-state-controller.ts b/app/scripts/controllers/app-state-controller.ts index e76b8fe3888e..605f307ec0e4 100644 --- a/app/scripts/controllers/app-state-controller.ts +++ b/app/scripts/controllers/app-state-controller.ts @@ -26,6 +26,7 @@ import { import { DEFAULT_AUTO_LOCK_TIME_LIMIT } from '../../../shared/constants/preferences'; import { LastInteractedConfirmationInfo } from '../../../shared/types/confirm'; import { SecurityAlertResponse } from '../lib/ppom/types'; +import { AccountOverviewTabKey } from '../../../shared/constants/app-state'; import type { Preferences, PreferencesControllerGetStateAction, @@ -35,7 +36,7 @@ import type { export type AppStateControllerState = { timeoutMinutes: number; connectedStatusPopoverHasBeenShown: boolean; - defaultHomeActiveTabName: string | null; + defaultHomeActiveTabName: AccountOverviewTabKey | null; browserEnvironment: Record; popupGasPollTokens: string[]; notificationGasPollTokens: string[]; @@ -326,7 +327,9 @@ export class AppStateController extends EventEmitter { * * @param defaultHomeActiveTabName - the tab name */ - setDefaultHomeActiveTabName(defaultHomeActiveTabName: string | null): void { + setDefaultHomeActiveTabName( + defaultHomeActiveTabName: AccountOverviewTabKey | null, + ): void { this.store.updateState({ defaultHomeActiveTabName, }); diff --git a/app/scripts/controllers/bridge/bridge-controller.test.ts b/app/scripts/controllers/bridge/bridge-controller.test.ts index 35449cb40764..8369d910f78b 100644 --- a/app/scripts/controllers/bridge/bridge-controller.test.ts +++ b/app/scripts/controllers/bridge/bridge-controller.test.ts @@ -6,6 +6,7 @@ import { flushPromises } from '../../../../test/lib/timer-helpers'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import * as bridgeUtil from '../../../../ui/pages/bridge/bridge.util'; +import * as balanceUtils from '../../../../shared/modules/bridge-utils/balance'; import BridgeController from './bridge-controller'; import { BridgeControllerMessenger } from './types'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from './constants'; @@ -21,11 +22,27 @@ const messengerMock = { publish: jest.fn(), } as unknown as jest.Mocked; +jest.mock('@ethersproject/contracts', () => { + return { + Contract: jest.fn(() => ({ + allowance: jest.fn(() => '100000000000000000000'), + })), + }; +}); + +jest.mock('@ethersproject/providers', () => { + return { + Web3Provider: jest.fn(), + }; +}); + describe('BridgeController', function () { let bridgeController: BridgeController; beforeAll(function () { - bridgeController = new BridgeController({ messenger: messengerMock }); + bridgeController = new BridgeController({ + messenger: messengerMock, + }); }); beforeEach(() => { @@ -37,11 +54,23 @@ describe('BridgeController', function () { .reply(200, { 'extension-config': { refreshRate: 3, - maxRefreshCount: 1, + maxRefreshCount: 3, }, 'extension-support': true, 'src-network-allowlist': [10, 534352], 'dest-network-allowlist': [137, 42161], + 'approval-gas-multiplier': { + '137': 1.1, + '42161': 1.2, + '10': 1.3, + '534352': 1.4, + }, + 'bridge-gas-multiplier': { + '137': 2.1, + '42161': 2.2, + '10': 2.3, + '534352': 2.4, + }, }); nock(BRIDGE_API_BASE_URL) .get('/getTokens?chainId=10') @@ -78,7 +107,7 @@ describe('BridgeController', function () { destNetworkAllowlist: [CHAIN_IDS.POLYGON, CHAIN_IDS.ARBITRUM], srcNetworkAllowlist: [CHAIN_IDS.OPTIMISM, CHAIN_IDS.SCROLL], extensionConfig: { - maxRefreshCount: 1, + maxRefreshCount: 3, refreshRate: 3, }, }; @@ -236,7 +265,13 @@ describe('BridgeController', function () { bridgeController, 'startPollingByNetworkClientId', ); - messengerMock.call.mockReturnValue({ address: '0x123' } as never); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(true); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); const fetchBridgeQuotesSpy = jest .spyOn(bridgeUtil, 'fetchBridgeQuotes') @@ -276,13 +311,17 @@ describe('BridgeController', function () { slippage: 0.5, walletAddress: '0x123', }; - bridgeController.updateBridgeQuoteRequestParams(quoteParams); + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith( - '1', - quoteRequest, + expect.anything(), + { + ...quoteRequest, + insufficientBal: false, + }, ); expect(bridgeController.state.bridgeState).toStrictEqual( @@ -299,14 +338,20 @@ describe('BridgeController', function () { jest.advanceTimersByTime(1000); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith(quoteRequest); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: false, + }, + expect.any(AbortSignal), + ); + expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( + undefined, + ); - const firstFetchTime = - bridgeController.state.bridgeState.quotesLastFetched ?? 0; - expect(firstFetchTime).toBeGreaterThan(0); expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest: { ...quoteRequest, insufficientBal: false }, quotes: [], quotesLoadingStatus: 0, }), @@ -317,26 +362,27 @@ describe('BridgeController', function () { await flushPromises(); expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest: { ...quoteRequest, insufficientBal: false }, quotes: [1, 2, 3], quotesLoadingStatus: 1, }), ); - expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( - firstFetchTime, - ); + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched ?? 0; + expect(firstFetchTime).toBeGreaterThan(0); // After 2nd fetch jest.advanceTimersByTime(50000); await flushPromises(); - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest: { ...quoteRequest, insufficientBal: false }, quotes: [5, 6, 7], quotesLoadingStatus: 1, + quotesRefreshCount: 2, }), ); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(2); const secondFetchTime = bridgeController.state.bridgeState.quotesLastFetched; expect(secondFetchTime).toBeGreaterThan(firstFetchTime); @@ -347,14 +393,140 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(3); expect(bridgeController.state.bridgeState).toEqual( expect.objectContaining({ - quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quoteRequest: { ...quoteRequest, insufficientBal: false }, quotes: [5, 6, 7], quotesLoadingStatus: 2, + quotesRefreshCount: 3, }), ); expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( secondFetchTime, ); + + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + }); + + it('updateBridgeQuoteRequestParams should only poll once if insufficientBal=true', async function () { + jest.useFakeTimers(); + const stopAllPollingSpy = jest.spyOn(bridgeController, 'stopAllPolling'); + const startPollingByNetworkClientIdSpy = jest.spyOn( + bridgeController, + 'startPollingByNetworkClientId', + ); + const hasSufficientBalanceSpy = jest + .spyOn(balanceUtils, 'hasSufficientBalance') + .mockResolvedValue(false); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + + const fetchBridgeQuotesSpy = jest + .spyOn(bridgeUtil, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([1, 2, 3] as never); + }, 5000); + }); + }); + + fetchBridgeQuotesSpy.mockImplementation(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve([5, 6, 7] as never); + }, 10000); + }); + }); + + const quoteParams = { + srcChainId: 1, + destChainId: 10, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + }; + const quoteRequest = { + ...quoteParams, + slippage: 0.5, + walletAddress: '0x123', + }; + await bridgeController.updateBridgeQuoteRequestParams(quoteParams); + + expect(stopAllPollingSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); + expect(startPollingByNetworkClientIdSpy).toHaveBeenCalledWith( + expect.anything(), + { + ...quoteRequest, + insufficientBal: true, + }, + ); + + expect(bridgeController.state.bridgeState).toStrictEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, walletAddress: undefined }, + quotes: DEFAULT_BRIDGE_CONTROLLER_STATE.quotes, + quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, + quotesLoadingStatus: + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + }), + ); + + // Loading state + jest.advanceTimersByTime(1000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( + { + ...quoteRequest, + insufficientBal: true, + }, + expect.any(AbortSignal), + ); + expect(bridgeController.state.bridgeState.quotesLastFetched).toStrictEqual( + undefined, + ); + + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [], + quotesLoadingStatus: 0, + }), + ); + + // After first fetch + jest.advanceTimersByTime(10000); + await flushPromises(); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [1, 2, 3], + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + const firstFetchTime = + bridgeController.state.bridgeState.quotesLastFetched ?? 0; + expect(firstFetchTime).toBeGreaterThan(0); + + // After 2nd fetch + jest.advanceTimersByTime(50000); + await flushPromises(); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(bridgeController.state.bridgeState).toEqual( + expect.objectContaining({ + quoteRequest: { ...quoteRequest, insufficientBal: true }, + quotes: [1, 2, 3], + quotesLoadingStatus: 1, + quotesRefreshCount: 1, + }), + ); + const secondFetchTime = + bridgeController.state.bridgeState.quotesLastFetched; + expect(secondFetchTime).toStrictEqual(firstFetchTime); }); it('updateBridgeQuoteRequestParams should not trigger quote polling if request is invalid', function () { @@ -363,7 +535,10 @@ describe('BridgeController', function () { bridgeController, 'startPollingByNetworkClientId', ); - messengerMock.call.mockReturnValueOnce({ address: '0x123' } as never); + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); bridgeController.updateBridgeQuoteRequestParams({ srcChainId: 1, @@ -392,4 +567,18 @@ describe('BridgeController', function () { }), ); }); + + describe('getBridgeERC20Allowance', () => { + it('should return the atomic allowance of the ERC20 token contract', async () => { + messengerMock.call.mockReturnValue({ + address: '0x123', + provider: jest.fn(), + } as never); + const allowance = await bridgeController.getBridgeERC20Allowance( + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + '0xa', + ); + expect(allowance).toBe('100000000000000000000'); + }); + }); }); diff --git a/app/scripts/controllers/bridge/bridge-controller.ts b/app/scripts/controllers/bridge/bridge-controller.ts index 1d20e6f404e4..2518e9caa9bd 100644 --- a/app/scripts/controllers/bridge/bridge-controller.ts +++ b/app/scripts/controllers/bridge/bridge-controller.ts @@ -1,7 +1,11 @@ -import { StateMetadata } from '@metamask/base-controller'; -import { Hex } from '@metamask/utils'; +import { add0x, Hex } from '@metamask/utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { NetworkClientId } from '@metamask/network-controller'; +import { StateMetadata } from '@metamask/base-controller'; +import { Contract } from '@ethersproject/contracts'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import { Web3Provider } from '@ethersproject/providers'; +import { BigNumber } from '@ethersproject/bignumber'; import { fetchBridgeFeatureFlags, fetchBridgeQuotes, @@ -19,11 +23,13 @@ import { QuoteRequest } from '../../../../ui/pages/bridge/types'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { isValidQuoteRequest } from '../../../../ui/pages/bridge/utils/quote'; +import { hasSufficientBalance } from '../../../../shared/modules/bridge-utils/balance'; import { BRIDGE_CONTROLLER_NAME, DEFAULT_BRIDGE_CONTROLLER_STATE, REFRESH_INTERVAL_MS, RequestStatus, + METABRIDGE_CHAIN_TO_ADDRESS_MAP, } from './constants'; import { BridgeControllerState, @@ -38,11 +44,15 @@ const metadata: StateMetadata<{ bridgeState: BridgeControllerState }> = { }, }; +const RESET_STATE_ABORT_MESSAGE = 'Reset controller state'; + export default class BridgeController extends StaticIntervalPollingController< typeof BRIDGE_CONTROLLER_NAME, { bridgeState: BridgeControllerState }, BridgeControllerMessenger > { + #abortController: AbortController | undefined; + constructor({ messenger }: { messenger: BridgeControllerMessenger }) { super({ name: BRIDGE_CONTROLLER_NAME, @@ -55,6 +65,8 @@ export default class BridgeController extends StaticIntervalPollingController< this.setIntervalLength(REFRESH_INTERVAL_MS); + this.#abortController = new AbortController(); + // Register action handlers this.messagingSystem.registerActionHandler( `${BRIDGE_CONTROLLER_NAME}:setBridgeFeatureFlags`, this.setBridgeFeatureFlags.bind(this), @@ -71,6 +83,14 @@ export default class BridgeController extends StaticIntervalPollingController< `${BRIDGE_CONTROLLER_NAME}:updateBridgeQuoteRequestParams`, this.updateBridgeQuoteRequestParams.bind(this), ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:resetState`, + this.resetState.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_CONTROLLER_NAME}:getBridgeERC20Allowance`, + this.getBridgeERC20Allowance.bind(this), + ); } _executePoll = async ( @@ -80,8 +100,12 @@ export default class BridgeController extends StaticIntervalPollingController< await this.#fetchBridgeQuotes(updatedQuoteRequest); }; - updateBridgeQuoteRequestParams = (paramsToUpdate: Partial) => { + updateBridgeQuoteRequestParams = async ( + paramsToUpdate: Partial, + ) => { this.stopAllPolling(); + this.#abortController?.abort('Quote request updated'); + const { bridgeState } = this.state; const updatedQuoteRequest = { ...DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest, @@ -96,23 +120,52 @@ export default class BridgeController extends StaticIntervalPollingController< quotesLastFetched: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLastFetched, quotesLoadingStatus: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesLoadingStatus, + quotesRefreshCount: DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount, }; }); if (isValidQuoteRequest(updatedQuoteRequest)) { const walletAddress = this.#getSelectedAccount().address; - this.startPollingByNetworkClientId( + const srcChainIdInHex = add0x( decimalToHex(updatedQuoteRequest.srcChainId), - { ...updatedQuoteRequest, walletAddress }, ); + + const insufficientBal = + paramsToUpdate.insufficientBal || + !(await this.#hasSufficientBalance(updatedQuoteRequest)); + + const networkClientId = this.#getSelectedNetworkClientId(srcChainIdInHex); + this.startPollingByNetworkClientId(networkClientId, { + ...updatedQuoteRequest, + walletAddress, + insufficientBal, + }); } }; + #hasSufficientBalance = async (quoteRequest: QuoteRequest) => { + const walletAddress = this.#getSelectedAccount().address; + const srcChainIdInHex = add0x(decimalToHex(quoteRequest.srcChainId)); + const provider = this.#getSelectedNetworkClient()?.provider; + + return ( + provider && + (await hasSufficientBalance( + provider, + walletAddress, + quoteRequest.srcTokenAddress, + quoteRequest.srcTokenAmount, + srcChainIdInHex, + )) + ); + }; + resetState = () => { this.stopAllPolling(); + this.#abortController?.abort(RESET_STATE_ABORT_MESSAGE); + this.update((_state) => { _state.bridgeState = { - ..._state.bridgeState, ...DEFAULT_BRIDGE_CONTROLLER_STATE, quotes: [], bridgeFeatureFlags: _state.bridgeState.bridgeFeatureFlags, @@ -142,32 +195,61 @@ export default class BridgeController extends StaticIntervalPollingController< }; #fetchBridgeQuotes = async (request: QuoteRequest) => { + this.#abortController?.abort('New quote request'); + this.#abortController = new AbortController(); + if (request.srcChainId === request.destChainId) { + return; + } const { bridgeState } = this.state; this.update((_state) => { _state.bridgeState = { ...bridgeState, - quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.LOADING, + quoteRequest: request, }; }); + const { maxRefreshCount } = + bridgeState.bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG]; + const newQuotesRefreshCount = bridgeState.quotesRefreshCount + 1; try { - const quotes = await fetchBridgeQuotes(request); + const quotes = await fetchBridgeQuotes( + request, + this.#abortController.signal, + ); + + // Stop polling if the maximum number of refreshes has been reached + if ( + (request.insufficientBal && newQuotesRefreshCount >= 1) || + (!request.insufficientBal && newQuotesRefreshCount >= maxRefreshCount) + ) { + this.stopAllPolling(); + } + this.update((_state) => { _state.bridgeState = { ..._state.bridgeState, quotes, + quotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, + quotesRefreshCount: newQuotesRefreshCount, }; }); } catch (error) { - console.log('Failed to fetch bridge quotes', error); + const isAbortError = (error as Error).name === 'AbortError'; + const isAbortedDueToReset = error === RESET_STATE_ABORT_MESSAGE; + if (isAbortedDueToReset || isAbortError) { + return; + } + this.update((_state) => { _state.bridgeState = { ...bridgeState, quotesLoadingStatus: RequestStatus.ERROR, + quotesRefreshCount: newQuotesRefreshCount, }; }); + console.log('Failed to fetch bridge quotes', error); } }; @@ -193,4 +275,42 @@ export default class BridgeController extends StaticIntervalPollingController< #getSelectedAccount() { return this.messagingSystem.call('AccountsController:getSelectedAccount'); } + + #getSelectedNetworkClient() { + return this.messagingSystem.call( + 'NetworkController:getSelectedNetworkClient', + ); + } + + #getSelectedNetworkClientId(chainId: Hex) { + return this.messagingSystem.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + } + + /** + * + * @param contractAddress - The address of the ERC20 token contract + * @param chainId - The hex chain ID of the bridge network + * @returns The atomic allowance of the ERC20 token contract + */ + getBridgeERC20Allowance = async ( + contractAddress: string, + chainId: Hex, + ): Promise => { + const provider = this.#getSelectedNetworkClient()?.provider; + if (!provider) { + throw new Error('No provider found'); + } + + const web3Provider = new Web3Provider(provider); + const contract = new Contract(contractAddress, abiERC20, web3Provider); + const { address: walletAddress } = this.#getSelectedAccount(); + const allowance = await contract.allowance( + walletAddress, + METABRIDGE_CHAIN_TO_ADDRESS_MAP[chainId], + ); + return BigNumber.from(allowance).toString(); + }; } diff --git a/app/scripts/controllers/bridge/constants.ts b/app/scripts/controllers/bridge/constants.ts index a4aa3264fdc8..ec60f8e6a0a4 100644 --- a/app/scripts/controllers/bridge/constants.ts +++ b/app/scripts/controllers/bridge/constants.ts @@ -1,4 +1,7 @@ import { zeroAddress } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; +import { METABRIDGE_ETHEREUM_ADDRESS } from '../../../../shared/constants/bridge'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { BridgeControllerState, BridgeFeatureFlagsKey } from './types'; export const BRIDGE_CONTROLLER_NAME = 'BridgeController'; @@ -34,4 +37,9 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { quotes: [], quotesLastFetched: undefined, quotesLoadingStatus: undefined, + quotesRefreshCount: 0, +}; + +export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record = { + [CHAIN_IDS.MAINNET]: METABRIDGE_ETHEREUM_ADDRESS, }; diff --git a/app/scripts/controllers/bridge/types.ts b/app/scripts/controllers/bridge/types.ts index 10c2d8646545..577a9fa99836 100644 --- a/app/scripts/controllers/bridge/types.ts +++ b/app/scripts/controllers/bridge/types.ts @@ -4,6 +4,10 @@ import { } from '@metamask/base-controller'; import { Hex } from '@metamask/utils'; import { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetSelectedNetworkClientAction, +} from '@metamask/network-controller'; import { SwapsTokenObject } from '../../../../shared/constants/swaps'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths @@ -38,6 +42,7 @@ export type BridgeControllerState = { quotes: QuoteResponse[]; quotesLastFetched?: number; quotesLoadingStatus?: RequestStatus; + quotesRefreshCount: number; }; export enum BridgeUserAction { @@ -47,6 +52,8 @@ export enum BridgeUserAction { } export enum BridgeBackgroundAction { SET_FEATURE_FLAGS = 'setBridgeFeatureFlags', + RESET_STATE = 'resetState', + GET_BRIDGE_ERC20_ALLOWANCE = 'getBridgeERC20Allowance', } type BridgeControllerAction = { @@ -57,6 +64,8 @@ type BridgeControllerAction = { // Maps to BridgeController function names type BridgeControllerActions = | BridgeControllerAction + | BridgeControllerAction + | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction | BridgeControllerAction; @@ -66,7 +75,10 @@ type BridgeControllerEvents = ControllerStateChangeEvent< BridgeControllerState >; -type AllowedActions = AccountsControllerGetSelectedAccountAction['type']; +type AllowedActions = + | AccountsControllerGetSelectedAccountAction['type'] + | NetworkControllerGetSelectedNetworkClientAction['type'] + | NetworkControllerFindNetworkClientIdByChainIdAction['type']; type AllowedEvents = never; /** @@ -74,7 +86,10 @@ type AllowedEvents = never; */ export type BridgeControllerMessenger = RestrictedControllerMessenger< typeof BRIDGE_CONTROLLER_NAME, - BridgeControllerActions | AccountsControllerGetSelectedAccountAction, + | BridgeControllerActions + | AccountsControllerGetSelectedAccountAction + | NetworkControllerGetSelectedNetworkClientAction + | NetworkControllerFindNetworkClientIdByChainIdAction, BridgeControllerEvents, AllowedActions, AllowedEvents diff --git a/app/scripts/controllers/metametrics-controller.test.ts b/app/scripts/controllers/metametrics-controller.test.ts new file mode 100644 index 000000000000..58fad403fdab --- /dev/null +++ b/app/scripts/controllers/metametrics-controller.test.ts @@ -0,0 +1,1968 @@ +import { toHex } from '@metamask/controller-utils'; +import type { + NetworkClientId, + NetworkState, +} from '@metamask/network-controller'; +import { NameEntry, NameType } from '@metamask/name-controller'; +import { AddressBookEntry } from '@metamask/address-book-controller'; +import { + Nft, + Token, + TokensControllerState, +} from '@metamask/assets-controllers'; +import { InternalAccount } from '@metamask/keyring-api'; +import { Browser } from 'webextension-polyfill'; +import { ControllerMessenger } from '@metamask/base-controller'; +import { merge } from 'lodash'; +import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; +import { createSegmentMock } from '../lib/segment'; +import { + METAMETRICS_ANONYMOUS_ID, + METAMETRICS_BACKGROUND_PAGE_OBJECT, + MetaMetricsUserTrait, + MetaMetricsUserTraits, +} from '../../../shared/constants/metametrics'; +import { CHAIN_IDS } from '../../../shared/constants/network'; +import { LedgerTransportTypes } from '../../../shared/constants/hardware-wallets'; +import * as Utils from '../lib/util'; +import { mockNetworkState } from '../../../test/stub/networks'; +import { flushPromises } from '../../../test/lib/timer-helpers'; +import MetaMetricsController, { + AllowedActions, + AllowedEvents, + MetaMetricsControllerOptions, +} from './metametrics-controller'; +import { + getDefaultPreferencesControllerState, + PreferencesControllerState, +} from './preferences-controller'; + +const segmentMock = createSegmentMock(2); + +const VERSION = '0.0.1-test'; +const DEFAULT_CHAIN_ID = '0x1338'; +const LOCALE = 'en_US'; +const TEST_META_METRICS_ID = '0xabc'; +const TEST_GA_COOKIE_ID = '123456.123455'; +const DUMMY_ACTION_ID = 'DUMMY_ACTION_ID'; +const MOCK_EXTENSION_ID = 'testid'; + +const MOCK_EXTENSION = { + runtime: { + id: MOCK_EXTENSION_ID, + setUninstallURL: () => undefined, + }, +} as unknown as Browser; + +const MOCK_TRAITS = { + test_boolean: true, + test_string: 'abc', + test_number: 123, + test_bool_array: [true, true, false], + test_string_array: ['test', 'test', 'test'], + test_boolean_array: [1, 2, 3], +} as MetaMetricsUserTraits; + +const MOCK_INVALID_TRAITS = { + test_null: null, + test_array_multi_types: [true, 'a', 1], +} as MetaMetricsUserTraits; + +const DEFAULT_TEST_CONTEXT = { + app: { + name: 'MetaMask Extension', + version: VERSION, + extensionId: MOCK_EXTENSION_ID, + }, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + referrer: undefined, + userAgent: window.navigator.userAgent, + marketingCampaignCookieId: null, +}; + +const DEFAULT_SHARED_PROPERTIES = { + chain_id: DEFAULT_CHAIN_ID, + locale: LOCALE.replace('_', '-'), + environment_type: 'background', +}; + +const DEFAULT_EVENT_PROPERTIES = { + category: 'Unit Test', + extensionId: MOCK_EXTENSION_ID, + ...DEFAULT_SHARED_PROPERTIES, +}; + +const DEFAULT_PAGE_PROPERTIES = { + ...DEFAULT_SHARED_PROPERTIES, +}; + +const SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT = { + id: 'transaction-submitted-0000', + canDeleteIfAbandoned: true, + category: 'Unit Test', + successEvent: 'Transaction Finalized', + persist: true, + properties: { + simulation_response: 'no_balance_change', + test_stored_prop: 1, + }, +}; + +const SAMPLE_PERSISTED_EVENT_NO_ID = { + persist: true, + category: 'Unit Test', + successEvent: 'sample persisted event success', + failureEvent: 'sample persisted event failure', + properties: { + test: true, + }, +}; + +const SAMPLE_PERSISTED_EVENT = { + id: 'testid', + ...SAMPLE_PERSISTED_EVENT_NO_ID, +}; + +const SAMPLE_NON_PERSISTED_EVENT = { + id: 'testid2', + persist: false, + category: 'Unit Test', + successEvent: 'sample non-persisted event success', + failureEvent: 'sample non-persisted event failure', + uniqueIdentifier: 'sample-non-persisted-event', + properties: { + test: true, + }, +}; + +describe('MetaMetricsController', function () { + describe('constructor', function () { + it('should properly initialize', async function () { + const spy = jest.spyOn(segmentMock, 'track'); + await withController(({ controller }) => { + expect(controller.version).toStrictEqual(VERSION); + expect(controller.chainId).toStrictEqual(DEFAULT_CHAIN_ID); + expect(controller.state.participateInMetaMetrics).toStrictEqual(true); + expect(controller.state.metaMetricsId).toStrictEqual( + TEST_META_METRICS_ID, + ); + expect(controller.state.marketingCampaignCookieId).toStrictEqual(null); + expect(controller.locale).toStrictEqual(LOCALE.replace('_', '-')); + expect(controller.state.fragments).toStrictEqual({ + testid: SAMPLE_PERSISTED_EVENT, + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'sample non-persisted event failure', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + test: true, + }, + messageId: 'sample-non-persisted-event-failure', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should update when network changes', async function () { + const selectedNetworkClientId = 'selectedNetworkClientId2'; + const selectedChainId = '0x222'; + await withController( + { + mockNetworkClientConfigurationsByNetworkClientId: { + [selectedNetworkClientId]: { + chainId: selectedChainId, + }, + }, + }, + ({ controller, triggerNetworkDidChange }) => { + triggerNetworkDidChange({ + networkConfigurationsByChainId: {}, + selectedNetworkClientId: 'selectedNetworkClientId2', + networksMetadata: {}, + }); + + expect(controller.chainId).toStrictEqual(selectedChainId); + }, + ); + }); + + it('should update when preferences changes', async function () { + await withController( + { + currentLocale: LOCALE, + }, + ({ controller, triggerPreferencesControllerStateChange }) => { + triggerPreferencesControllerStateChange({ + ...getDefaultPreferencesControllerState(), + currentLocale: 'en_UK', + }); + expect(controller.locale).toStrictEqual('en-UK'); + }, + ); + }); + }); + + describe('createEventFragment', function () { + it('should throw an error if the param is missing successEvent or category', async function () { + await withController(async ({ controller }) => { + await expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error because we are testing the error case + controller.createEventFragment({ event: 'test' }); + }).toThrow(/Must specify success event and category\./u); + + await expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error because we are testing the error case + controller.createEventFragment({ category: 'test' }); + }).toThrow(/Must specify success event and category\./u); + }); + }); + + it('should update fragments state with new fragment', async function () { + await withController(({ controller }) => { + jest.useFakeTimers().setSystemTime(1730798301422); + const mockNewId = 'testid3'; + + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + uniqueIdentifier: mockNewId, + }); + + const resultFragment = controller.state.fragments[mockNewId]; + + expect(resultFragment).toStrictEqual({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + id: mockNewId, + uniqueIdentifier: mockNewId, + lastUpdated: 1730798301422, + }); + }); + }); + + it('should track the initial event if provided', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + const mockInitialEventName = 'Test Initial Event'; + + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + initialEvent: mockInitialEventName, + }); + + expect(spy).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('should not call track if no initialEvent was provided', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + }); + + expect(spy).toHaveBeenCalledTimes(0); + }, + ); + }); + + describe('when intialEvent is "Transaction Submitted" and a fragment exists before createEventFragment is called', function () { + it('should update existing fragment state with new fragment props', async function () { + await withController(({ controller }) => { + jest.useFakeTimers().setSystemTime(1730798302222); + const { id } = SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT; + + controller.updateEventFragment( + SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT.id, + { + ...SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT, + }, + ); + controller.createEventFragment({ + ...SAMPLE_PERSISTED_EVENT_NO_ID, + initialEvent: 'Transaction Submitted', + uniqueIdentifier: id, + }); + + const expectedFragment = merge( + SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT, + SAMPLE_PERSISTED_EVENT_NO_ID, + { + canDeleteIfAbandoned: false, + id, + initialEvent: 'Transaction Submitted', + uniqueIdentifier: id, + lastUpdated: 1730798302222, + }, + ); + + expect(controller.state.fragments[id]).toStrictEqual( + expectedFragment, + ); + }); + }); + }); + }); + + describe('updateEventFragment', function () { + it('updates fragment with additional provided props', async function () { + await withController(({ controller }) => { + jest.useFakeTimers().setSystemTime(1730798303333); + + const MOCK_PROPS_TO_UPDATE = { + properties: { + test: 1, + }, + }; + + controller.updateEventFragment( + SAMPLE_PERSISTED_EVENT.id, + MOCK_PROPS_TO_UPDATE, + ); + + const expectedPartialFragment = { + ...SAMPLE_PERSISTED_EVENT, + ...MOCK_PROPS_TO_UPDATE, + lastUpdated: 1730798303333, + }; + + expect( + controller.state.fragments[SAMPLE_PERSISTED_EVENT.id], + ).toStrictEqual(expectedPartialFragment); + }); + }); + + it('throws error when no existing fragment exists', async function () { + await withController(async ({ controller }) => { + jest.useFakeTimers().setSystemTime(1730798303333); + + const MOCK_NONEXISTING_ID = 'test-nonexistingid'; + + await expect(() => { + controller.updateEventFragment(MOCK_NONEXISTING_ID, { + properties: { test: 1 }, + }); + }).toThrow( + /Event fragment with id test-nonexistingid does not exist\./u, + ); + + jest.useRealTimers(); + }); + }); + + describe('when id includes "transaction-submitted"', function () { + it('creates and stores new fragment props with canDeleteIfAbandoned set to true', async function () { + await withController(({ controller }) => { + jest.useFakeTimers().setSystemTime(1730798303333); + const MOCK_ID = 'transaction-submitted-1111'; + const MOCK_PROPS_TO_UPDATE = { + properties: { + test: 1, + }, + }; + + controller.updateEventFragment(MOCK_ID, MOCK_PROPS_TO_UPDATE); + + const resultFragment = controller.state.fragments[MOCK_ID]; + const expectedPartialFragment = { + ...MOCK_PROPS_TO_UPDATE, + category: 'Transactions', + canDeleteIfAbandoned: true, + id: MOCK_ID, + lastUpdated: 1730798303333, + successEvent: 'Transaction Finalized', + }; + expect(resultFragment).toStrictEqual(expectedPartialFragment); + + jest.useRealTimers(); + }); + }); + }); + }); + + describe('generateMetaMetricsId', function () { + it('should generate an 0x prefixed hex string', async function () { + await withController(({ controller }) => { + expect( + controller.generateMetaMetricsId().startsWith('0x'), + ).toStrictEqual(true); + }); + }); + }); + + describe('getMetaMetricsId', function () { + it('should generate or return the metametrics id', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: null, + }, + }, + }, + ({ controller }) => { + // Starts off being empty. + expect(controller.state.metaMetricsId).toStrictEqual(null); + + // Create a new metametrics id. + const clientMetaMetricsId = controller.getMetaMetricsId(); + expect(clientMetaMetricsId.startsWith('0x')).toStrictEqual(true); + + // Return same metametrics id. + const sameMetaMetricsId = controller.getMetaMetricsId(); + expect(clientMetaMetricsId).toStrictEqual(sameMetaMetricsId); + }, + ); + }); + }); + + describe('identify', function () { + it('should call segment.identify for valid traits if user is participating in metametrics', async function () { + const spy = jest.spyOn(segmentMock, 'identify'); + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }, + }, + }, + ({ controller }) => { + controller.identify({ + ...MOCK_TRAITS, + ...MOCK_INVALID_TRAITS, + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + userId: TEST_META_METRICS_ID, + traits: MOCK_TRAITS, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + + it('should transform date type traits into ISO-8601 timestamp strings', async function () { + const spy = jest.spyOn(segmentMock, 'identify'); + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }, + }, + }, + ({ controller }) => { + controller.identify({ + test_date: new Date().toISOString(), + } as MetaMetricsUserTraits); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + userId: TEST_META_METRICS_ID, + traits: { + test_date: new Date().toISOString(), + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + + it('should not call segment.identify if user is not participating in metametrics', async function () { + const spy = jest.spyOn(segmentMock, 'identify'); + await withController( + { + options: { + state: { + participateInMetaMetrics: false, + }, + }, + }, + ({ controller }) => { + controller.identify(MOCK_TRAITS); + expect(spy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should not call segment.identify if there are no valid traits to identify', async function () { + const spy = jest.spyOn(segmentMock, 'identify'); + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + }, + }, + }, + ({ controller }) => { + controller.identify(MOCK_INVALID_TRAITS); + expect(spy).toHaveBeenCalledTimes(0); + }, + ); + }); + }); + + describe('setParticipateInMetaMetrics', function () { + it('should update the value of participateInMetaMetrics', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: null, + metaMetricsId: null, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.participateInMetaMetrics).toStrictEqual(null); + await controller.setParticipateInMetaMetrics(true); + expect(controller.state.participateInMetaMetrics).toStrictEqual(true); + await controller.setParticipateInMetaMetrics(false); + expect(controller.state.participateInMetaMetrics).toStrictEqual( + false, + ); + }, + ); + }); + it('should generate and update the metaMetricsId when set to true', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: null, + metaMetricsId: null, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.metaMetricsId).toStrictEqual(null); + await controller.setParticipateInMetaMetrics(true); + expect(typeof controller.state.metaMetricsId).toStrictEqual('string'); + }, + ); + }); + it('should not nullify the metaMetricsId when set to false', async function () { + await withController(async ({ controller }) => { + await controller.setParticipateInMetaMetrics(false); + expect(controller.state.metaMetricsId).toStrictEqual( + TEST_META_METRICS_ID, + ); + }); + }); + it('should nullify the marketingCampaignCookieId when participateInMetaMetrics is toggled off', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + dataCollectionForMarketing: true, + marketingCampaignCookieId: TEST_GA_COOKIE_ID, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.marketingCampaignCookieId).toStrictEqual( + TEST_GA_COOKIE_ID, + ); + await controller.setParticipateInMetaMetrics(false); + expect(controller.state.marketingCampaignCookieId).toStrictEqual( + null, + ); + }, + ); + }); + }); + + describe('trackEvent', function () { + it('should not track an event if user is not participating in metametrics', async function () { + const spy = jest.spyOn(segmentMock, 'track'); + await withController( + { + options: { + state: { + participateInMetaMetrics: false, + }, + }, + }, + ({ controller }) => { + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }); + expect(spy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should track an event if user has not opted in, but isOptIn is true', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }, + { isOptIn: true }, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: '1', + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + + it('should track an event during optin and allow for metaMetricsId override', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }, + { isOptIn: true, metaMetricsId: 'TESTID' }, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: 'TESTID', + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: '1', + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + + it('should track a legacy event', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }, + { matomoEvent: true }, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + legacy_event: true, + chain_id: '1', + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should track a non legacy event', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: '1', + }, + context: DEFAULT_TEST_CONTEXT, + userId: TEST_META_METRICS_ID, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should immediately flush queue if flushImmediately set to true', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'flush'); + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + }, + { flushImmediately: true }, + ); + expect(spy).not.toThrow(); + }); + }); + + it('should throw if event or category not provided', async function () { + await withController(({ controller }) => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error because we are testing the error case + controller.trackEvent({ event: 'test' }); + }).toThrow(/Must specify event and category\./u); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error because we are testing the error case + controller.trackEvent({ category: 'test' }); + }).toThrow(/Must specify event and category\./u); + }); + }); + + it('should throw if provided sensitiveProperties, when excludeMetaMetricsId is true', async function () { + const captureExceptionMock = jest.fn(); + await withController( + { + options: { + captureException: captureExceptionMock, + }, + }, + async ({ controller }) => { + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }, + { excludeMetaMetricsId: true }, + ); + await flushPromises(); + expect(captureExceptionMock).toHaveBeenCalledWith( + new Error( + 'sensitiveProperties was specified in an event payload that also set the excludeMetaMetricsId flag', + ), + ); + }, + ); + }); + + it('should track sensitiveProperties in a separate, anonymous event', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: DEFAULT_EVENT_PROPERTIES, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[1][1], + ); + }); + }); + }); + + describe('Change Signature XXX anonymous event names', function () { + // @ts-expect-error This function is missing from the Mocha type definitions + it.each([ + ['Signature Requested', 'Signature Requested Anon'], + ['Signature Rejected', 'Signature Rejected Anon'], + ['Signature Approved', 'Signature Approved Anon'], + ])( + 'should change "%s" anonymous event names to "%s"', + async (eventType: string, anonEventType: string) => { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: eventType, + category: 'Unit Test', + properties: DEFAULT_EVENT_PROPERTIES, + sensitiveProperties: { foo: 'bar' }, + }); + + expect(spy).toHaveBeenCalledTimes(2); + + expect(spy.mock.calls[0][0]).toMatchObject({ + event: anonEventType, + properties: { foo: 'bar', ...DEFAULT_EVENT_PROPERTIES }, + }); + + expect(spy.mock.calls[1][0]).toMatchObject({ + event: eventType, + properties: { ...DEFAULT_EVENT_PROPERTIES }, + }); + }); + }, + ); + }); + + describe('Change Transaction XXX anonymous event namnes', function () { + it('should change "Transaction Added" anonymous event names to "Transaction Added Anon"', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Transaction Added', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: `Transaction Added Anon`, + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should change "Transaction Submitted" anonymous event names to "Transaction Added Anon"', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Transaction Submitted', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: `Transaction Submitted Anon`, + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should change "Transaction Finalized" anonymous event names to "Transaction Added Anon"', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Transaction Finalized', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: `Transaction Finalized Anon`, + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + }); + + describe('trackPage', function () { + it('should track a page view', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage({ + name: 'home', + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + name: 'home', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + params: undefined, + ...DEFAULT_PAGE_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should not track a page view if user is not participating in metametrics', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: false, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage({ + name: 'home', + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }); + expect(spy).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('should track a page view if isOptInPath is true and user not yet opted in', async function () { + await withController( + { + currentLocale: LOCALE, + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage( + { + name: 'home', + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }, + { isOptInPath: true }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + name: 'home', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_PAGE_PROPERTIES, + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + + it('multiple trackPage call with same actionId should result in same messageId being sent to segment', async function () { + await withController( + { + currentLocale: LOCALE, + options: { + state: { + participateInMetaMetrics: true, + }, + }, + }, + ({ controller }) => { + const spy = jest.spyOn(segmentMock, 'page'); + controller.trackPage( + { + name: 'home', + actionId: DUMMY_ACTION_ID, + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }, + { isOptInPath: true }, + ); + controller.trackPage( + { + name: 'home', + actionId: DUMMY_ACTION_ID, + environmentType: ENVIRONMENT_TYPE_BACKGROUND, + page: METAMETRICS_BACKGROUND_PAGE_OBJECT, + }, + { isOptInPath: true }, + ); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + name: 'home', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: DEFAULT_PAGE_PROPERTIES, + messageId: DUMMY_ACTION_ID, + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + }); + + describe('deterministic messageId', function () { + it('should use the actionId as messageId when provided', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: 'bar', + }, + actionId: '0x001', + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: 'bar', + }, + messageId: '0x001', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should append 0x000 to the actionId of anonymized event when tracking sensitiveProperties', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + actionId: '0x001', + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: '0x001-0x000', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: DEFAULT_EVENT_PROPERTIES, + messageId: '0x001', + timestamp: new Date(), + }, + spy.mock.calls[1][1], + ); + }); + }); + + it('should use the uniqueIdentifier as messageId when provided', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: 'bar', + }, + uniqueIdentifier: 'transaction-submitted-0000', + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: 'bar', + }, + messageId: 'transaction-submitted-0000', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should append 0x000 to the uniqueIdentifier of anonymized event when tracking sensitiveProperties', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + uniqueIdentifier: 'transaction-submitted-0000', + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: 'transaction-submitted-0000-0x000', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: 'transaction-submitted-0000', + timestamp: new Date(), + }, + spy.mock.calls[1][1], + ); + }); + }); + + it('should combine the uniqueIdentifier and actionId as messageId when both provided', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + properties: { chain_id: 'bar' }, + actionId: '0x001', + uniqueIdentifier: 'transaction-submitted-0000', + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: 'bar', + }, + messageId: 'transaction-submitted-0000-0x001', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }); + }); + + it('should append 0x000 to the combined uniqueIdentifier and actionId of anonymized event when tracking sensitiveProperties', async function () { + await withController(({ controller }) => { + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent({ + event: 'Fake Event', + category: 'Unit Test', + sensitiveProperties: { foo: 'bar' }, + actionId: '0x001', + uniqueIdentifier: 'transaction-submitted-0000', + }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + foo: 'bar', + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: 'transaction-submitted-0000-0x001-0x000', + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + userId: TEST_META_METRICS_ID, + context: DEFAULT_TEST_CONTEXT, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + }, + messageId: 'transaction-submitted-0000-0x001', + timestamp: new Date(), + }, + spy.mock.calls[1][1], + ); + }); + }); + }); + + describe('_buildUserTraitsObject', function () { + it('should return full user traits object on first call', async function () { + const MOCK_ALL_TOKENS: TokensControllerState['allTokens'] = { + [toHex(1)]: { + '0x1235ce91d74254f29d4609f25932fe6d97bf4842': [ + { + address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', + }, + { + address: '0xabc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', + }, + ] as Token[], + '0xe364b0f9d1879e53e8183055c9d7dd2b7375d86b': [ + { + address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', + }, + ] as Token[], + }, + [toHex(4)]: { + '0x1235ce91d74254f29d4609f25932fe6d97bf4842': [ + { + address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', + }, + { + address: '0x12317F958D2ee523a2206206994597C13D831ec7', + }, + ] as Token[], + }, + }; + + await withController(({ controller }) => { + const traits = controller._buildUserTraitsObject({ + addressBook: { + [CHAIN_IDS.MAINNET]: { + '0x': { + address: '0x', + } as AddressBookEntry, + }, + [CHAIN_IDS.GOERLI]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x0': { + address: '0x0', + } as AddressBookEntry, + }, + }, + allNfts: { + '0xac706cE8A9BF27Afecf080fB298d0ee13cfb978A': { + [toHex(56)]: [ + { + address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', + tokenId: '100', + }, + { + address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', + tokenId: '101', + }, + { + address: '0x7488d2ce5deb26db021285b50b661d655eb3d3d9', + tokenId: '99', + }, + ] as Nft[], + }, + '0xe04AB39684A24D8D4124b114F3bd6FBEB779cacA': { + [toHex(59)]: [ + { + address: '0x63d646bc7380562376d5de205123a57b1718184d', + tokenId: '14', + }, + ] as Nft[], + }, + }, + allTokens: MOCK_ALL_TOKENS, + ...mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.GOERLI }, + { chainId: '0xaf' }, + ), + internalAccounts: { + accounts: { + mock1: {} as InternalAccount, + mock2: {} as InternalAccount, + }, + selectedAccount: 'mock1', + }, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: true, + useNftDetection: false, + securityAlertsEnabled: true, + theme: 'default', + useTokenDetection: true, + ShowNativeTokenAsMainBalance: true, + security_providers: [], + names: { + [NameType.ETHEREUM_ADDRESS]: { + '0x123': { + '0x1': { + name: 'Test 1', + } as NameEntry, + '0x2': { + name: 'Test 2', + } as NameEntry, + '0x3': { + name: null, + } as NameEntry, + }, + '0x456': { + '0x1': { + name: 'Test 3', + } as NameEntry, + }, + '0x789': { + '0x1': { + name: null, + } as NameEntry, + }, + }, + }, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + participateInMetaMetrics: true, + currentCurrency: 'usd', + dataCollectionForMarketing: false, + preferences: { privacyMode: true }, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + custodyAccountDetails: {}, + ///: END:ONLY_INCLUDE_IF + }); + + expect(traits).toStrictEqual({ + [MetaMetricsUserTrait.AddressBookEntries]: 3, + [MetaMetricsUserTrait.InstallDateExt]: '', + [MetaMetricsUserTrait.LedgerConnectionType]: + LedgerTransportTypes.webhid, + [MetaMetricsUserTrait.NetworksAdded]: [ + CHAIN_IDS.MAINNET, + CHAIN_IDS.GOERLI, + '0xaf', + ], + [MetaMetricsUserTrait.NetworksWithoutTicker]: ['0xaf'], + [MetaMetricsUserTrait.NftAutodetectionEnabled]: false, + [MetaMetricsUserTrait.NumberOfAccounts]: 2, + [MetaMetricsUserTrait.NumberOfNftCollections]: 3, + [MetaMetricsUserTrait.NumberOfNfts]: 4, + [MetaMetricsUserTrait.NumberOfTokens]: 5, + [MetaMetricsUserTrait.OpenSeaApiEnabled]: true, + [MetaMetricsUserTrait.ThreeBoxEnabled]: false, + [MetaMetricsUserTrait.Theme]: 'default', + [MetaMetricsUserTrait.TokenDetectionEnabled]: true, + [MetaMetricsUserTrait.ShowNativeTokenAsMainBalance]: true, + [MetaMetricsUserTrait.CurrentCurrency]: 'usd', + [MetaMetricsUserTrait.HasMarketingConsent]: false, + [MetaMetricsUserTrait.SecurityProviders]: ['blockaid'], + [MetaMetricsUserTrait.IsMetricsOptedIn]: true, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + [MetaMetricsUserTrait.MmiExtensionId]: 'testid', + [MetaMetricsUserTrait.MmiAccountAddress]: null, + [MetaMetricsUserTrait.MmiIsCustodian]: false, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(petnames) + [MetaMetricsUserTrait.PetnameAddressCount]: 3, + ///: END:ONLY_INCLUDE_IF + [MetaMetricsUserTrait.TokenSortPreference]: 'token-sort-key', + [MetaMetricsUserTrait.PrivacyModeEnabled]: true, + }); + }); + }); + + it('should return only changed traits object on subsequent calls', async function () { + await withController(({ controller }) => { + const networkState = mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.GOERLI }, + ); + controller._buildUserTraitsObject({ + addressBook: { + [CHAIN_IDS.MAINNET]: { + '0x': { + address: '0x', + } as AddressBookEntry, + }, + [CHAIN_IDS.GOERLI]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x0': { + address: '0x0', + } as AddressBookEntry, + }, + }, + allTokens: {}, + ...networkState, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: true, + internalAccounts: { + accounts: { + mock1: {} as InternalAccount, + mock2: {} as InternalAccount, + }, + selectedAccount: 'mock1', + }, + useNftDetection: false, + theme: 'default', + useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + ShowNativeTokenAsMainBalance: true, + allNfts: {}, + participateInMetaMetrics: true, + dataCollectionForMarketing: false, + preferences: { privacyMode: true }, + securityAlertsEnabled: true, + names: { + ethereumAddress: {}, + }, + security_providers: ['blockaid'], + currentCurrency: 'usd', + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + custodyAccountDetails: {}, + ///: END:ONLY_INCLUDE_IF + }); + + const updatedTraits = controller._buildUserTraitsObject({ + addressBook: { + [CHAIN_IDS.MAINNET]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x1': { + address: '0x1', + } as AddressBookEntry, + }, + [CHAIN_IDS.GOERLI]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x0': { + address: '0x0', + } as AddressBookEntry, + }, + }, + allTokens: { + [toHex(1)]: { + '0xabcde': [{ address: '0xtestAddress' } as Token], + }, + }, + ...networkState, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: false, + internalAccounts: { + accounts: { + mock1: {} as InternalAccount, + mock2: {} as InternalAccount, + mock3: {} as InternalAccount, + }, + selectedAccount: 'mock1', + }, + useNftDetection: false, + theme: 'default', + useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + ShowNativeTokenAsMainBalance: false, + names: { + ethereumAddress: {}, + }, + security_providers: ['blockaid'], + currentCurrency: 'usd', + allNfts: {}, + participateInMetaMetrics: true, + dataCollectionForMarketing: false, + preferences: { privacyMode: true }, + securityAlertsEnabled: true, + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + custodyAccountDetails: {}, + ///: END:ONLY_INCLUDE_IF + }); + + expect(updatedTraits).toStrictEqual({ + [MetaMetricsUserTrait.AddressBookEntries]: 4, + [MetaMetricsUserTrait.NumberOfAccounts]: 3, + [MetaMetricsUserTrait.NumberOfTokens]: 1, + [MetaMetricsUserTrait.OpenSeaApiEnabled]: false, + [MetaMetricsUserTrait.ShowNativeTokenAsMainBalance]: false, + }); + }); + }); + + it('should return null if no traits changed', async function () { + await withController(({ controller }) => { + const networkState = mockNetworkState( + { chainId: CHAIN_IDS.MAINNET }, + { chainId: CHAIN_IDS.GOERLI }, + ); + controller._buildUserTraitsObject({ + addressBook: { + [CHAIN_IDS.MAINNET]: { + '0x': { + address: '0x', + } as AddressBookEntry, + }, + [CHAIN_IDS.GOERLI]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x0': { + address: '0x0', + } as AddressBookEntry, + }, + }, + allTokens: {}, + ...networkState, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: true, + internalAccounts: { + accounts: { + mock1: {} as InternalAccount, + mock2: {} as InternalAccount, + }, + selectedAccount: 'mock1', + }, + useNftDetection: true, + theme: 'default', + useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + ShowNativeTokenAsMainBalance: true, + allNfts: {}, + names: { + ethereumAddress: {}, + }, + participateInMetaMetrics: true, + dataCollectionForMarketing: false, + preferences: { privacyMode: true }, + securityAlertsEnabled: true, + security_providers: ['blockaid'], + currentCurrency: 'usd', + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + custodyAccountDetails: {}, + ///: END:ONLY_INCLUDE_IF + }); + + const updatedTraits = controller._buildUserTraitsObject({ + addressBook: { + [CHAIN_IDS.MAINNET]: { + '0x': { + address: '0x', + } as AddressBookEntry, + }, + [CHAIN_IDS.GOERLI]: { + '0x': { + address: '0x', + } as AddressBookEntry, + '0x0': { address: '0x0' } as AddressBookEntry, + }, + }, + allTokens: {}, + ...networkState, + ledgerTransportType: LedgerTransportTypes.webhid, + openSeaEnabled: true, + internalAccounts: { + accounts: { + mock1: {} as InternalAccount, + mock2: {} as InternalAccount, + }, + selectedAccount: 'mock1', + }, + useNftDetection: true, + theme: 'default', + useTokenDetection: true, + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + ShowNativeTokenAsMainBalance: true, + allNfts: {}, + participateInMetaMetrics: true, + dataCollectionForMarketing: false, + preferences: { privacyMode: true }, + names: { + ethereumAddress: {}, + }, + securityAlertsEnabled: true, + security_providers: ['blockaid'], + currentCurrency: 'usd', + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) + custodyAccountDetails: {}, + ///: END:ONLY_INCLUDE_IF + }); + expect(updatedTraits).toStrictEqual(null); + }); + }); + }); + + describe('submitting segmentApiCalls to segment SDK', function () { + it('should add event to store when submitting to SDK', async function () { + await withController(({ controller }) => { + controller.trackPage({}, { isOptInPath: true }); + const { segmentApiCalls } = controller.state; + expect(Object.keys(segmentApiCalls).length > 0).toStrictEqual(true); + }); + }); + + it('should remove event from store when callback is invoked', async function () { + const segmentInstance = createSegmentMock(2); + const stubFn = (...args: unknown[]) => { + const cb = args[1] as () => void; + cb(); + }; + jest.spyOn(segmentInstance, 'track').mockImplementation(stubFn); + jest.spyOn(segmentInstance, 'page').mockImplementation(stubFn); + + await withController( + { + options: { + segment: segmentInstance, + }, + }, + ({ controller }) => { + controller.trackPage({}, { isOptInPath: true }); + const { segmentApiCalls } = controller.state; + expect(Object.keys(segmentApiCalls).length === 0).toStrictEqual(true); + }, + ); + }); + }); + describe('setMarketingCampaignCookieId', function () { + it('should update marketingCampaignCookieId in the context when cookieId is available', async function () { + await withController( + { + options: { + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + dataCollectionForMarketing: true, + }, + }, + }, + ({ controller }) => { + controller.setMarketingCampaignCookieId(TEST_GA_COOKIE_ID); + expect(controller.state.marketingCampaignCookieId).toStrictEqual( + TEST_GA_COOKIE_ID, + ); + const spy = jest.spyOn(segmentMock, 'track'); + controller.trackEvent( + { + event: 'Fake Event', + category: 'Unit Test', + properties: { + chain_id: '1', + }, + }, + { isOptIn: true }, + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith( + { + event: 'Fake Event', + anonymousId: METAMETRICS_ANONYMOUS_ID, + context: { + ...DEFAULT_TEST_CONTEXT, + marketingCampaignCookieId: TEST_GA_COOKIE_ID, + }, + properties: { + ...DEFAULT_EVENT_PROPERTIES, + chain_id: '1', + }, + messageId: Utils.generateRandomId(), + timestamp: new Date(), + }, + spy.mock.calls[0][1], + ); + }, + ); + }); + }); + describe('setDataCollectionForMarketing', function () { + it('should nullify the marketingCampaignCookieId when Data collection for marketing is toggled off', async function () { + await withController( + { + options: { + state: { + metaMetricsId: TEST_META_METRICS_ID, + dataCollectionForMarketing: true, + marketingCampaignCookieId: TEST_GA_COOKIE_ID, + }, + }, + }, + async ({ controller }) => { + expect(controller.state.marketingCampaignCookieId).toStrictEqual( + TEST_GA_COOKIE_ID, + ); + await controller.setDataCollectionForMarketing(false); + expect(controller.state.marketingCampaignCookieId).toStrictEqual( + null, + ); + }, + ); + }); + }); +}); + +type WithControllerOptions = { + currentLocale?: string; + options?: Partial; + mockNetworkClientConfigurationsByNetworkClientId?: Record< + NetworkClientId, + { + chainId: string; + } + >; +}; + +type WithControllerCallback = ({ + controller, + triggerPreferencesControllerStateChange, + triggerNetworkDidChange, +}: { + controller: MetaMetricsController; + triggerPreferencesControllerStateChange: ( + state: PreferencesControllerState, + ) => void; + triggerNetworkDidChange(state: NetworkState): void; +}) => ReturnValue; + +type WithControllerArgs = + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback]; + +async function withController( + ...args: WithControllerArgs +): Promise { + try { + globalThis.sentry = {}; + jest.useFakeTimers().setSystemTime(new Date().getTime()); + jest.spyOn(Utils, 'generateRandomId').mockReturnValue('DUMMY_RANDOM_ID'); + + const [{ ...rest }, fn] = args.length === 2 ? args : [{}, args[0]]; + const { + options = {}, + currentLocale = LOCALE, + mockNetworkClientConfigurationsByNetworkClientId = { + selectedNetworkClientId: { + chainId: DEFAULT_CHAIN_ID, + }, + }, + } = rest; + const controllerMessenger = new ControllerMessenger< + AllowedActions, + AllowedEvents + >(); + + controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + jest.fn().mockReturnValue({ + currentLocale, + }), + ); + + controllerMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: Object.keys( + mockNetworkClientConfigurationsByNetworkClientId, + )[0], + }), + ); + + controllerMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: Object.values( + mockNetworkClientConfigurationsByNetworkClientId, + )[0], + }), + ); + + return fn({ + controller: new MetaMetricsController({ + segment: segmentMock, + messenger: controllerMessenger.getRestricted({ + name: 'MetaMetricsController', + allowedActions: [ + 'PreferencesController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'NetworkController:networkDidChange', + ], + }), + version: '0.0.1', + environment: 'test', + extension: MOCK_EXTENSION, + ...options, + state: { + participateInMetaMetrics: true, + metaMetricsId: TEST_META_METRICS_ID, + marketingCampaignCookieId: null, + fragments: { + testid: SAMPLE_PERSISTED_EVENT, + testid2: SAMPLE_NON_PERSISTED_EVENT, + }, + ...options.state, + }, + }), + triggerPreferencesControllerStateChange: (state) => + controllerMessenger.publish( + 'PreferencesController:stateChange', + state, + [], + ), + triggerNetworkDidChange: (state) => + controllerMessenger.publish( + 'NetworkController:networkDidChange', + state, + ), + }); + } finally { + // flush the queues manually after each test + segmentMock.flush(); + jest.useRealTimers(); + jest.restoreAllMocks(); + } +} diff --git a/app/scripts/controllers/metametrics.ts b/app/scripts/controllers/metametrics-controller.ts similarity index 83% rename from app/scripts/controllers/metametrics.ts rename to app/scripts/controllers/metametrics-controller.ts index ded99dd917f4..d29a2840eb27 100644 --- a/app/scripts/controllers/metametrics.ts +++ b/app/scripts/controllers/metametrics-controller.ts @@ -8,7 +8,6 @@ import { size, sum, } from 'lodash'; -import { ObservableStore } from '@metamask/obs-store'; import { bufferToHex, keccak } from 'ethereumjs-util'; import { v4 as uuidv4 } from 'uuid'; import { NameControllerState, NameType } from '@metamask/name-controller'; @@ -19,7 +18,13 @@ import { isErrorWithMessage, isErrorWithStack, } from '@metamask/utils'; -import { NetworkState } from '@metamask/network-controller'; +import { + NetworkClientId, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerNetworkDidChangeEvent, + NetworkState, +} from '@metamask/network-controller'; import { Browser } from 'webextension-polyfill'; import { Nft, @@ -27,6 +32,12 @@ import { TokensControllerState, } from '@metamask/assets-controllers'; import { captureException as sentryCaptureException } from '@sentry/browser'; +import { + BaseController, + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; import { AddressBookControllerState } from '@metamask/address-book-controller'; import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; import { @@ -61,7 +72,14 @@ import Analytics from '../lib/segment/analytics'; import { ENVIRONMENT } from '../../../development/build/constants'; ///: END:ONLY_INCLUDE_IF -import type { PreferencesControllerState } from './preferences-controller'; +import type { + PreferencesControllerState, + PreferencesControllerGetStateAction, + PreferencesControllerStateChangeEvent, +} from './preferences-controller'; + +// Unique name for the controller +const controllerName = 'MetaMetricsController'; const EXTENSION_UNINSTALL_URL = 'https://metamask.io/uninstalled'; @@ -146,6 +164,9 @@ export type MetaMaskState = { security_providers: string[]; addressBook: AddressBookControllerState['addressBook']; currentCurrency: string; + preferences: { + privacyMode: PreferencesControllerState['preferences']['privacyMode']; + }; ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) custodyAccountDetails: { [address: string]: { @@ -155,6 +176,56 @@ export type MetaMaskState = { ///: END:ONLY_INCLUDE_IF }; +/** + * {@link MetaMetricsController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const controllerMetadata = { + metaMetricsId: { + persist: true, + anonymous: true, + }, + participateInMetaMetrics: { + persist: true, + anonymous: true, + }, + latestNonAnonymousEventTimestamp: { + persist: true, + anonymous: true, + }, + fragments: { + persist: true, + anonymous: false, + }, + eventsBeforeMetricsOptIn: { + persist: true, + anonymous: false, + }, + traits: { + persist: true, + anonymous: false, + }, + previousUserTraits: { + persist: true, + anonymous: false, + }, + dataCollectionForMarketing: { + persist: true, + anonymous: false, + }, + marketingCampaignCookieId: { + persist: true, + anonymous: true, + }, + segmentApiCalls: { + persist: true, + anonymous: false, + }, +}; + /** * The state that MetaMetricsController stores. * @@ -189,28 +260,91 @@ export type MetaMetricsControllerState = { >; }; +/** + * Returns the state of the {@link MetaMetricsController}. + */ +export type MetaMetricsControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + MetaMetricsControllerState +>; + +/** + * Actions exposed by the {@link MetaMetricsController}. + */ +export type MetaMetricsControllerActions = MetaMetricsControllerGetStateAction; + +/** + * Event emitted when the state of the {@link MetaMetricsController} changes. + */ +export type MetaMetricsControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + MetaMetricsControllerState +>; + +export type MetaMetricsControllerEvents = MetaMetricsControllerStateChangeEvent; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | PreferencesControllerGetStateAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = + | PreferencesControllerStateChangeEvent + | NetworkControllerNetworkDidChangeEvent; + +/** + * Messenger type for the {@link MetaMetricsController}. + */ +export type MetaMetricsControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + MetaMetricsControllerActions | AllowedActions, + MetaMetricsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + type CaptureException = | typeof sentryCaptureException | ((err: unknown) => void); export type MetaMetricsControllerOptions = { - initState: Partial; + state?: Partial; + messenger: MetaMetricsControllerMessenger; segment: Analytics; - preferencesControllerState: PreferencesControllerState; - onPreferencesStateChange: ( - listener: (state: PreferencesControllerState) => void, - ) => void; - onNetworkDidChange: (listener: (networkState: NetworkState) => void) => void; - getCurrentChainId: () => Hex; version: string; environment: string; extension: Browser; captureException?: CaptureException; }; -export default class MetaMetricsController { - store: ObservableStore; +/** + * Function to get default state of the {@link MetaMetricsController}. + */ +export const getDefaultMetaMetricsControllerState = + (): MetaMetricsControllerState => ({ + participateInMetaMetrics: null, + metaMetricsId: null, + dataCollectionForMarketing: null, + marketingCampaignCookieId: null, + latestNonAnonymousEventTimestamp: 0, + eventsBeforeMetricsOptIn: [], + traits: {}, + previousUserTraits: {}, + fragments: {}, + segmentApiCalls: {}, + }); +export default class MetaMetricsController extends BaseController< + typeof controllerName, + MetaMetricsControllerState, + MetaMetricsControllerMessenger +> { #captureException: CaptureException; chainId: Hex; @@ -231,32 +365,34 @@ export default class MetaMetricsController { /** * @param options + * @param options.state - Initial controller state. + * @param options.messenger - Messenger used to communicate with BaseV2 controller. * @param options.segment - an instance of analytics for tracking * events that conform to the new MetaMetrics tracking plan. - * @param options.preferencesControllerState - The state of preferences controller - * @param options.onPreferencesStateChange - Used to attach a listener to the - * stateChange event emitted by the PreferencesController - * @param options.onNetworkDidChange - Used to attach a listener to the - * networkDidChange event emitted by the networkController - * @param options.getCurrentChainId - Gets the current chain id from the network controller. * @param options.version - The version of the extension * @param options.environment - The environment the extension is running in * @param options.extension - webextension-polyfill - * @param options.initState - State to initialized with * @param options.captureException */ constructor({ + state = {}, + messenger, segment, - preferencesControllerState, - onPreferencesStateChange, - onNetworkDidChange, - getCurrentChainId, version, environment, - initState, extension, captureException = defaultCaptureException, }: MetaMetricsControllerOptions) { + super({ + name: controllerName, + metadata: controllerMetadata, + state: { + ...getDefaultMetaMetricsControllerState(), + ...state, + }, + messenger, + }); + this.#captureException = (err: unknown) => { const message = getErrorMessage(err); // This is a temporary measure. Currently there are errors flooding sentry due to a problem in how we are tracking anonymousId @@ -265,7 +401,10 @@ export default class MetaMetricsController { captureException(err); } }; - this.chainId = getCurrentChainId(); + this.chainId = this.#getCurrentChainId(); + const preferencesControllerState = this.messagingSystem.call( + 'PreferencesController:getState', + ); this.locale = preferencesControllerState.currentLocale.replace('_', '-'); this.version = environment === 'production' ? version : `${version}-${environment}`; @@ -276,34 +415,21 @@ export default class MetaMetricsController { this.#selectedAddress = preferencesControllerState.selectedAddress; ///: END:ONLY_INCLUDE_IF - const abandonedFragments = omitBy(initState?.fragments, 'persist'); - const segmentApiCalls = initState?.segmentApiCalls || {}; - - this.store = new ObservableStore({ - participateInMetaMetrics: null, - metaMetricsId: null, - dataCollectionForMarketing: null, - marketingCampaignCookieId: null, - latestNonAnonymousEventTimestamp: 0, - eventsBeforeMetricsOptIn: [], - traits: {}, - previousUserTraits: {}, - ...initState, - fragments: { - ...initState?.fragments, - }, - segmentApiCalls: { - ...segmentApiCalls, - }, - }); + const abandonedFragments = omitBy(state.fragments, 'persist'); - onPreferencesStateChange(({ currentLocale }) => { - this.locale = currentLocale?.replace('_', '-'); - }); + this.messagingSystem.subscribe( + 'PreferencesController:stateChange', + ({ currentLocale }) => { + this.locale = currentLocale?.replace('_', '-'); + }, + ); - onNetworkDidChange(() => { - this.chainId = getCurrentChainId(); - }); + this.messagingSystem.subscribe( + 'NetworkController:networkDidChange', + ({ selectedNetworkClientId }) => { + this.chainId = this.#getCurrentChainId(selectedNetworkClientId); + }, + ); this.#segment = segment; // Track abandoned fragments that weren't properly cleaned up. @@ -318,13 +444,15 @@ export default class MetaMetricsController { // Code below submits any pending segmentApiCalls to Segment if/when the controller is re-instantiated if (isManifestV3) { - Object.values(segmentApiCalls).forEach(({ eventType, payload }) => { - try { - this.#submitSegmentAPICall(eventType, payload); - } catch (error) { - this.#captureException(error); - } - }); + Object.values(state.segmentApiCalls || {}).forEach( + ({ eventType, payload }) => { + try { + this.#submitSegmentAPICall(eventType, payload); + } catch (error) { + this.#captureException(error); + } + }, + ); } // Close out event fragments that were created but not progressed. An @@ -362,8 +490,27 @@ export default class MetaMetricsController { } } + /** + * Gets the current chain ID. + * + * @param networkClientId - The network client ID to get the chain ID for. + */ + #getCurrentChainId(networkClientId?: NetworkClientId): Hex { + const selectedNetworkClientId = + networkClientId || + this.messagingSystem.call('NetworkController:getState') + .selectedNetworkClientId; + const { + configuration: { chainId }, + } = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + return chainId; + } + finalizeAbandonedFragments(): void { - Object.values(this.store.getState().fragments).forEach((fragment) => { + Object.values(this.state.fragments).forEach((fragment) => { if ( fragment.timeout && fragment.lastUpdated && @@ -407,7 +554,7 @@ export default class MetaMetricsController { ); } - const { fragments } = this.store.getState(); + const { fragments } = this.state; const id = options.uniqueIdentifier ?? uuidv4(); const fragment = { @@ -435,11 +582,9 @@ export default class MetaMetricsController { } : {}; - this.store.updateState({ - fragments: { - ...fragments, - [id]: merge(additionalFragmentProps, fragment), - }, + this.update((state) => { + // @ts-expect-error this is caused by a bug in Immer, not being able to handle recursive types like Json + state.fragments[id] = merge(additionalFragmentProps, fragment); }); if (fragment.initialEvent) { @@ -469,11 +614,7 @@ export default class MetaMetricsController { * @param id - id of fragment to retrieve */ getEventFragmentById(id: string): MetaMetricsEventFragment { - const { fragments } = this.store.getState(); - - const fragment = fragments[id]; - - return fragment; + return this.state.fragments[id]; } /** @@ -499,7 +640,7 @@ export default class MetaMetricsController { id: string, payload: Partial, ): void { - const { fragments } = this.store.getState(); + const { fragments } = this.state; const fragment = fragments[id]; @@ -512,24 +653,26 @@ export default class MetaMetricsController { const createIfNotFound = !fragment && id.includes('transaction-submitted-'); if (createIfNotFound) { - fragments[id] = { - canDeleteIfAbandoned: true, - category: MetaMetricsEventCategory.Transactions, - successEvent: TransactionMetaMetricsEvent.finalized, - id, - }; + this.update((state) => { + state.fragments[id] = { + canDeleteIfAbandoned: true, + category: MetaMetricsEventCategory.Transactions, + successEvent: TransactionMetaMetricsEvent.finalized, + id, + ...payload, + lastUpdated: Date.now(), + }; + }); + return; } else if (!fragment) { throw new Error(`Event fragment with id ${id} does not exist.`); } - this.store.updateState({ - fragments: { - ...fragments, - [id]: merge(fragments[id], { - ...payload, - lastUpdated: Date.now(), - }), - }, + this.update((state) => { + state.fragments[id] = merge(state.fragments[id], { + ...payload, + lastUpdated: Date.now(), + }); }); } @@ -539,10 +682,10 @@ export default class MetaMetricsController { * @param id - The fragment id to delete */ deleteEventFragment(id: string): void { - const { fragments } = this.store.getState(); - - if (fragments[id]) { - delete fragments[id]; + if (this.state.fragments[id]) { + this.update((state) => { + delete state.fragments[id]; + }); } } @@ -569,7 +712,7 @@ export default class MetaMetricsController { referrer?: MetaMetricsReferrerObject; } = {}, ): void { - const fragment = this.store.getState().fragments[id]; + const fragment = this.state.fragments[id]; if (!fragment) { throw new Error(`Funnel with id ${id} does not exist.`); } @@ -597,9 +740,9 @@ export default class MetaMetricsController { ? `${fragment.uniqueIdentifier}-${abandoned ? 'failure' : 'success'}` : undefined, }); - const { fragments } = this.store.getState(); - delete fragments[id]; - this.store.updateState({ fragments }); + this.update((state) => { + delete state.fragments[id]; + }); } /** @@ -669,7 +812,10 @@ export default class MetaMetricsController { ? this.generateMetaMetricsId() : existingMetaMetricsId; - this.store.updateState({ participateInMetaMetrics, metaMetricsId }); + this.update((state) => { + state.participateInMetaMetrics = participateInMetaMetrics; + state.metaMetricsId = metaMetricsId; + }); if (participateInMetaMetrics) { this.trackEventsAfterMetricsOptIn(); @@ -695,7 +841,9 @@ export default class MetaMetricsController { ): MetaMetricsControllerState['metaMetricsId'] { const { metaMetricsId } = this.state; - this.store.updateState({ dataCollectionForMarketing }); + this.update((state) => { + state.dataCollectionForMarketing = dataCollectionForMarketing; + }); if (!dataCollectionForMarketing && this.state.marketingCampaignCookieId) { this.setMarketingCampaignCookieId(null); @@ -705,11 +853,9 @@ export default class MetaMetricsController { } setMarketingCampaignCookieId(marketingCampaignCookieId: string | null): void { - this.store.updateState({ marketingCampaignCookieId }); - } - - get state(): MetaMetricsControllerState { - return this.store.getState(); + this.update((state) => { + state.marketingCampaignCookieId = marketingCampaignCookieId; + }); } /** @@ -767,10 +913,10 @@ export default class MetaMetricsController { options?: MetaMetricsEventOptions, ): void { // validation is not caught and handled - this.validatePayload(payload); - this.submitEvent(payload, options).catch((err) => - this.#captureException(err), - ); + this.#validatePayload(payload); + this.#submitEvent(payload, options).catch((err) => { + this.#captureException(err); + }); } /** @@ -782,12 +928,10 @@ export default class MetaMetricsController { * @param payload - details of the event * @param options - options for handling/routing the event */ - async submitEvent( + async #submitEvent( payload: MetaMetricsEventPayload, options?: MetaMetricsEventOptions, ): Promise { - this.validatePayload(payload); - if (!this.state.participateInMetaMetrics && !options?.isOptIn) { return; } @@ -816,8 +960,8 @@ export default class MetaMetricsController { }; const combinedProperties = merge( - anonymousPayload.sensitiveProperties, - anonymousPayload.properties, + { ...anonymousPayload.sensitiveProperties }, + { ...anonymousPayload.properties }, ); events.push( @@ -842,7 +986,7 @@ export default class MetaMetricsController { * * @param payload - details of the event */ - validatePayload(payload: MetaMetricsEventPayload): void { + #validatePayload(payload: MetaMetricsEventPayload): void { // event and category are required fields for all payloads if (!payload.event || !payload.category) { throw new Error( @@ -868,7 +1012,7 @@ export default class MetaMetricsController { // Track all queued events after a user opted into metrics. trackEventsAfterMetricsOptIn(): void { - const { eventsBeforeMetricsOptIn } = this.store.getState(); + const { eventsBeforeMetricsOptIn } = this.state; eventsBeforeMetricsOptIn.forEach((eventBeforeMetricsOptIn) => { this.trackEvent(eventBeforeMetricsOptIn); }); @@ -876,24 +1020,22 @@ export default class MetaMetricsController { // Once we track queued events after a user opts into metrics, we want to clear the event queue. clearEventsAfterMetricsOptIn(): void { - this.store.updateState({ - eventsBeforeMetricsOptIn: [], + this.update((state) => { + state.eventsBeforeMetricsOptIn = []; }); } // It adds an event into a queue, which is only tracked if a user opts into metrics. addEventBeforeMetricsOptIn(event: MetaMetricsEventPayload): void { - const prevState = this.store.getState().eventsBeforeMetricsOptIn; - this.store.updateState({ - eventsBeforeMetricsOptIn: [...prevState, event], + this.update((state) => { + state.eventsBeforeMetricsOptIn.push(event); }); } // Add or update traits for tracking. updateTraits(newTraits: MetaMetricsUserTraits): void { - const { traits } = this.store.getState(); - this.store.updateState({ - traits: { ...traits, ...newTraits }, + this.update((state) => { + state.traits = { ...state.traits, ...newTraits }; }); } @@ -902,7 +1044,9 @@ export default class MetaMetricsController { let { metaMetricsId } = this.state; if (!metaMetricsId) { metaMetricsId = this.generateMetaMetricsId(); - this.store.updateState({ metaMetricsId }); + this.update((state) => { + state.metaMetricsId = metaMetricsId; + }); } return metaMetricsId; } @@ -1033,13 +1177,11 @@ export default class MetaMetricsController { ? Object.keys(metamaskState.custodyAccountDetails)[0] : null; ///: END:ONLY_INCLUDE_IF - const { traits, previousUserTraits } = this.store.getState(); + const { traits, previousUserTraits } = this.state; const currentTraits = { [MetaMetricsUserTrait.AddressBookEntries]: sum( - Object.values(metamaskState.addressBook).map((v) => - size(v as object | string | null | undefined), - ), + Object.values(metamaskState.addressBook).map(size), ), [MetaMetricsUserTrait.InstallDateExt]: traits[MetaMetricsUserTrait.InstallDateExt] || '', @@ -1089,11 +1231,13 @@ export default class MetaMetricsController { metamaskState.dataCollectionForMarketing, [MetaMetricsUserTrait.TokenSortPreference]: metamaskState.tokenSortConfig?.key || '', + [MetaMetricsUserTrait.PrivacyModeEnabled]: + metamaskState.preferences.privacyMode, }; if (!previousUserTraits) { - this.store.updateState({ - previousUserTraits: currentTraits, + this.update((state) => { + state.previousUserTraits = currentTraits; }); return currentTraits; } @@ -1104,7 +1248,9 @@ export default class MetaMetricsController { const previous = previousUserTraits[k]; return !isEqual(previous, v); }); - this.store.updateState({ previousUserTraits: currentTraits }); + this.update((state) => { + state.previousUserTraits = currentTraits; + }); return updates; } @@ -1369,30 +1515,24 @@ export default class MetaMetricsController { messageId, timestamp, }; - this.store.updateState({ - ...this.store.getState(), - latestNonAnonymousEventTimestamp: + this.update((state) => { + state.latestNonAnonymousEventTimestamp = modifiedPayload.anonymousId === METAMETRICS_ANONYMOUS_ID ? latestNonAnonymousEventTimestamp - : timestamp.valueOf(), - // @ts-expect-error The reason this is needed is that the event property in the payload can be missing, - // whereas the state expects it to be present. It's unclear how best to handle this discrepancy. - segmentApiCalls: { - ...this.store.getState().segmentApiCalls, - [messageId]: { - eventType, - payload: { - ...modifiedPayload, - timestamp: modifiedPayload.timestamp.toString(), - }, + : timestamp.valueOf(); + state.segmentApiCalls[messageId] = { + eventType, + // @ts-expect-error The reason this is needed is that the event property in the payload can be missing, + // whereas the state expects it to be present. It's unclear how best to handle this discrepancy. + payload: { + ...modifiedPayload, + timestamp: modifiedPayload.timestamp.toString(), }, - }, + }; }); const modifiedCallback = (result: unknown) => { - const { segmentApiCalls } = this.store.getState(); - delete segmentApiCalls[messageId]; - this.store.updateState({ - segmentApiCalls, + this.update((state) => { + delete state.segmentApiCalls[messageId]; }); return callback?.(result); }; diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts index be22cbdf77c0..9d5dc3259ed4 100644 --- a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.test.ts @@ -1,5 +1,6 @@ import { ControllerMessenger } from '@metamask/base-controller'; import { + AllowedActions, MetaMetricsDataDeletionController, type MetaMetricsDataDeletionControllerMessengerActions, } from './metametrics-data-deletion'; @@ -10,8 +11,8 @@ describe('MetaMetricsDataDeletionController', () => { const mockMetaMetricsId = 'mockId'; const mockTaskId = 'mockTaskId'; const { controller, dataDeletionService } = setupController({ + metaMetricsId: mockMetaMetricsId, options: { - getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), dataDeletionService: { createDataDeletionRegulationTask: jest .fn() @@ -43,8 +44,8 @@ describe('MetaMetricsDataDeletionController', () => { const mockMetaMetricsId = 'mockId'; const mockTaskId = 'mockTaskId'; const { controller, dataDeletionService } = setupController({ + metaMetricsId: mockMetaMetricsId, options: { - getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), dataDeletionService: { createDataDeletionRegulationTask: jest .fn() @@ -76,9 +77,7 @@ describe('MetaMetricsDataDeletionController', () => { it('fails to creates a data deletion task when user has never participating in metrics tracking', async () => { const { controller } = setupController({ - options: { - getMetaMetricsId: jest.fn().mockReturnValue(null), - }, + metaMetricsId: null, }); await expect( controller.createMetaMetricsDataDeletionTask(), @@ -94,8 +93,8 @@ describe('MetaMetricsDataDeletionController', () => { const mockMetaMetricsId = 'mockId'; const mockTaskId = 'mockTaskId'; const { controller, dataDeletionService } = setupController({ + metaMetricsId: mockMetaMetricsId, options: { - getMetaMetricsId: jest.fn().mockReturnValue(mockMetaMetricsId), dataDeletionService: { createDataDeletionRegulationTask: jest .fn() @@ -128,28 +127,35 @@ describe('MetaMetricsDataDeletionController', () => { * * @param options - Setup options. * @param options.options - Controller constructor options. + * @param options.metaMetricsId - The MetaMetrics ID to use. * @returns The test controller, a messenger instance, and related mocks. */ function setupController({ options, + metaMetricsId, }: { options?: Partial< ConstructorParameters[0] >; + metaMetricsId?: string | null; } = {}): { controller: MetaMetricsDataDeletionController; dataDeletionService: ConstructorParameters< typeof MetaMetricsDataDeletionController >[0]['dataDeletionService']; messenger: ControllerMessenger< - MetaMetricsDataDeletionControllerMessengerActions, + MetaMetricsDataDeletionControllerMessengerActions | AllowedActions, never >; } { - const messenger = new ControllerMessenger< - MetaMetricsDataDeletionControllerMessengerActions, + const controllerMessenger = new ControllerMessenger< + MetaMetricsDataDeletionControllerMessengerActions | AllowedActions, never >(); + controllerMessenger.registerActionHandler( + 'MetaMetricsController:getState', + jest.fn().mockReturnValue({ metaMetricsId }), + ); const mockCreateDataDeletionRegulationTaskResponse = 'mockRegulateId'; const mockFetchDeletionRegulationStatusResponse = 'UNKNOWN'; const mockDataDeletionService = { @@ -164,9 +170,9 @@ function setupController({ const constructorOptions = { dataDeletionService: mockDataDeletionService, getMetaMetricsId: jest.fn().mockReturnValue('mockMetaMetricsId'), - messenger: messenger.getRestricted({ + messenger: controllerMessenger.getRestricted({ name: 'MetaMetricsDataDeletionController', - allowedActions: [], + allowedActions: ['MetaMetricsController:getState'], allowedEvents: [], }), ...options, @@ -176,6 +182,6 @@ function setupController({ return { controller, dataDeletionService: constructorOptions.dataDeletionService, - messenger, + messenger: controllerMessenger, }; } diff --git a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts index 8aad81c3721d..ec3c431ee38f 100644 --- a/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts +++ b/app/scripts/controllers/metametrics-data-deletion/metametrics-data-deletion.ts @@ -5,6 +5,7 @@ import { import { PublicInterface } from '@metamask/utils'; import type { DataDeletionService } from '../../services/data-deletion-service'; import { DeleteRegulationStatus } from '../../../../shared/constants/metametrics'; +import { MetaMetricsControllerGetStateAction } from '../metametrics-controller'; // Unique name for the controller const controllerName = 'MetaMetricsDataDeletionController'; @@ -70,14 +71,24 @@ export type MetaMetricsDataDeletionControllerMessengerActions = | CreateMetaMetricsDataDeletionTaskAction | UpdateDataDeletionTaskStatusAction; +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = MetaMetricsControllerGetStateAction; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = never; + // Type for the messenger of MetaMetricsDataDeletionController export type MetaMetricsDataDeletionControllerMessenger = RestrictedControllerMessenger< typeof controllerName, - MetaMetricsDataDeletionControllerMessengerActions, - never, - never, - never + MetaMetricsDataDeletionControllerMessengerActions | AllowedActions, + AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] >; /** @@ -91,8 +102,6 @@ export class MetaMetricsDataDeletionController extends BaseController< > { #dataDeletionService: PublicInterface; - #getMetaMetricsId: () => string | null; - /** * Creates a MetaMetricsDataDeletionController instance. * @@ -100,18 +109,15 @@ export class MetaMetricsDataDeletionController extends BaseController< * @param args.dataDeletionService - The service used for deleting data. * @param args.messenger - Messenger used to communicate with BaseV2 controller. * @param args.state - Initial state to set on this controller. - * @param args.getMetaMetricsId - A function that returns the current MetaMetrics ID. */ constructor({ dataDeletionService, messenger, state, - getMetaMetricsId, }: { dataDeletionService: PublicInterface; messenger: MetaMetricsDataDeletionControllerMessenger; state?: Partial; - getMetaMetricsId: () => string | null; }) { // Call the constructor of BaseControllerV2 super({ @@ -120,7 +126,6 @@ export class MetaMetricsDataDeletionController extends BaseController< name: controllerName, state: { ...getDefaultState(), ...state }, }); - this.#getMetaMetricsId = getMetaMetricsId; this.#dataDeletionService = dataDeletionService; this.#registerMessageHandlers(); } @@ -146,7 +151,9 @@ export class MetaMetricsDataDeletionController extends BaseController< * */ async createMetaMetricsDataDeletionTask(): Promise { - const metaMetricsId = this.#getMetaMetricsId(); + const { metaMetricsId } = this.messagingSystem.call( + 'MetaMetricsController:getState', + ); if (!metaMetricsId) { throw new Error('MetaMetrics ID not found'); } diff --git a/app/scripts/controllers/metametrics.test.ts b/app/scripts/controllers/metametrics.test.ts deleted file mode 100644 index 4b2a1f09a562..000000000000 --- a/app/scripts/controllers/metametrics.test.ts +++ /dev/null @@ -1,1734 +0,0 @@ -import { toHex } from '@metamask/controller-utils'; -import { NetworkState } from '@metamask/network-controller'; -import { NameEntry, NameType } from '@metamask/name-controller'; -import { AddressBookEntry } from '@metamask/address-book-controller'; -import { - Nft, - Token, - TokensControllerState, -} from '@metamask/assets-controllers'; -import { InternalAccount } from '@metamask/keyring-api'; -import { Browser } from 'webextension-polyfill'; -import { Hex } from '@metamask/utils'; -import { merge } from 'lodash'; -import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; -import { createSegmentMock } from '../lib/segment'; -import { - METAMETRICS_ANONYMOUS_ID, - METAMETRICS_BACKGROUND_PAGE_OBJECT, - MetaMetricsUserTrait, - MetaMetricsUserTraits, -} from '../../../shared/constants/metametrics'; -import { CHAIN_IDS } from '../../../shared/constants/network'; -import { LedgerTransportTypes } from '../../../shared/constants/hardware-wallets'; -import * as Utils from '../lib/util'; -import { mockNetworkState } from '../../../test/stub/networks'; -import MetaMetricsController, { - MetaMetricsControllerOptions, - MetaMetricsControllerState, -} from './metametrics'; -import { - getDefaultPreferencesControllerState, - PreferencesControllerState, -} from './preferences-controller'; - -const segmentMock = createSegmentMock(2); - -const VERSION = '0.0.1-test'; -const FAKE_CHAIN_ID = '0x1338'; -const LOCALE = 'en_US'; -const TEST_META_METRICS_ID = '0xabc'; -const TEST_GA_COOKIE_ID = '123456.123455'; -const DUMMY_ACTION_ID = 'DUMMY_ACTION_ID'; -const MOCK_EXTENSION_ID = 'testid'; - -const MOCK_EXTENSION = { - runtime: { - id: MOCK_EXTENSION_ID, - setUninstallURL: () => undefined, - }, -} as unknown as Browser; - -const MOCK_TRAITS = { - test_boolean: true, - test_string: 'abc', - test_number: 123, - test_bool_array: [true, true, false], - test_string_array: ['test', 'test', 'test'], - test_boolean_array: [1, 2, 3], -} as MetaMetricsUserTraits; - -const MOCK_INVALID_TRAITS = { - test_null: null, - test_array_multi_types: [true, 'a', 1], -} as MetaMetricsUserTraits; - -const DEFAULT_TEST_CONTEXT = { - app: { - name: 'MetaMask Extension', - version: VERSION, - extensionId: MOCK_EXTENSION_ID, - }, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - referrer: undefined, - userAgent: window.navigator.userAgent, - marketingCampaignCookieId: null, -}; - -const DEFAULT_SHARED_PROPERTIES = { - chain_id: FAKE_CHAIN_ID, - locale: LOCALE.replace('_', '-'), - environment_type: 'background', -}; - -const DEFAULT_EVENT_PROPERTIES = { - category: 'Unit Test', - revenue: undefined, - value: undefined, - currency: undefined, - extensionId: MOCK_EXTENSION_ID, - ...DEFAULT_SHARED_PROPERTIES, -}; - -const DEFAULT_PAGE_PROPERTIES = { - ...DEFAULT_SHARED_PROPERTIES, -}; - -const SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT = { - id: 'transaction-submitted-0000', - canDeleteIfAbandoned: true, - category: 'Unit Test', - successEvent: 'Transaction Finalized', - persist: true, - properties: { - simulation_response: 'no_balance_change', - test_stored_prop: 1, - }, -}; - -const SAMPLE_PERSISTED_EVENT_NO_ID = { - persist: true, - category: 'Unit Test', - successEvent: 'sample persisted event success', - failureEvent: 'sample persisted event failure', - properties: { - test: true, - }, -}; - -const SAMPLE_PERSISTED_EVENT = { - id: 'testid', - ...SAMPLE_PERSISTED_EVENT_NO_ID, -}; - -const SAMPLE_NON_PERSISTED_EVENT = { - id: 'testid2', - persist: false, - category: 'Unit Test', - successEvent: 'sample non-persisted event success', - failureEvent: 'sample non-persisted event failure', - uniqueIdentifier: 'sample-non-persisted-event', - properties: { - test: true, - }, -}; - -function getMetaMetricsController({ - participateInMetaMetrics = true, - metaMetricsId = TEST_META_METRICS_ID, - marketingCampaignCookieId = null, - currentLocale = LOCALE, - onPreferencesStateChange = () => { - // do nothing - }, - getCurrentChainId = () => FAKE_CHAIN_ID, - onNetworkDidChange = () => { - // do nothing - }, - segment = segmentMock, -}: { - currentLocale?: string; - participateInMetaMetrics?: MetaMetricsControllerState['participateInMetaMetrics']; - metaMetricsId?: MetaMetricsControllerState['metaMetricsId']; - dataCollectionForMarketing?: MetaMetricsControllerState['dataCollectionForMarketing']; - marketingCampaignCookieId?: MetaMetricsControllerState['marketingCampaignCookieId']; - onPreferencesStateChange?: MetaMetricsControllerOptions['onPreferencesStateChange']; - getCurrentChainId?: MetaMetricsControllerOptions['getCurrentChainId']; - onNetworkDidChange?: MetaMetricsControllerOptions['onNetworkDidChange']; - segment?: MetaMetricsControllerOptions['segment']; -} = {}) { - return new MetaMetricsController({ - segment, - getCurrentChainId, - onNetworkDidChange, - preferencesControllerState: { - ...getDefaultPreferencesControllerState(), - currentLocale, - }, - onPreferencesStateChange, - version: '0.0.1', - environment: 'test', - initState: { - participateInMetaMetrics, - metaMetricsId, - marketingCampaignCookieId, - fragments: { - testid: SAMPLE_PERSISTED_EVENT, - testid2: SAMPLE_NON_PERSISTED_EVENT, - }, - }, - extension: MOCK_EXTENSION, - }); -} - -describe('MetaMetricsController', function () { - const now = new Date(); - beforeEach(function () { - globalThis.sentry = {}; - jest.useFakeTimers().setSystemTime(now.getTime()); - jest.spyOn(Utils, 'generateRandomId').mockReturnValue('DUMMY_RANDOM_ID'); - }); - - describe('constructor', function () { - it('should properly initialize', function () { - const spy = jest.spyOn(segmentMock, 'track'); - const metaMetricsController = getMetaMetricsController(); - expect(metaMetricsController.version).toStrictEqual(VERSION); - expect(metaMetricsController.chainId).toStrictEqual(FAKE_CHAIN_ID); - expect( - metaMetricsController.state.participateInMetaMetrics, - ).toStrictEqual(true); - expect(metaMetricsController.state.metaMetricsId).toStrictEqual( - TEST_META_METRICS_ID, - ); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(null); - expect(metaMetricsController.locale).toStrictEqual( - LOCALE.replace('_', '-'), - ); - expect(metaMetricsController.state.fragments).toStrictEqual({ - testid: SAMPLE_PERSISTED_EVENT, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'sample non-persisted event failure', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - test: true, - }, - messageId: 'sample-non-persisted-event-failure', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should update when network changes', function () { - let chainId: Hex = '0x111'; - let networkDidChangeListener: (state: NetworkState) => void = () => { - // do nothing - }; - const onNetworkDidChange: ( - listener: (state: NetworkState) => void, - ) => void = (listener) => { - networkDidChangeListener = listener; - }; - const metaMetricsController = getMetaMetricsController({ - getCurrentChainId: () => chainId, - onNetworkDidChange, - }); - - chainId = '0x222'; - - networkDidChangeListener({} as NetworkState); - - expect(metaMetricsController.chainId).toStrictEqual('0x222'); - }); - - it('should update when preferences changes', function () { - let subscribeListener: ( - state: PreferencesControllerState, - ) => void = () => { - // do nothing - }; - const onPreferencesStateChange: MetaMetricsControllerOptions['onPreferencesStateChange'] = - (listener) => { - subscribeListener = listener; - }; - const metaMetricsController = getMetaMetricsController({ - currentLocale: LOCALE, - onPreferencesStateChange, - }); - - subscribeListener({ - ...getDefaultPreferencesControllerState(), - currentLocale: 'en_UK', - }); - expect(metaMetricsController.locale).toStrictEqual('en-UK'); - }); - }); - - describe('createEventFragment', function () { - it('should throw an error if the param is missing successEvent or category', async function () { - const metaMetricsController = getMetaMetricsController(); - - await expect(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error because we are testing the error case - metaMetricsController.createEventFragment({ event: 'test' }); - }).toThrow(/Must specify success event and category\./u); - - await expect(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error because we are testing the error case - metaMetricsController.createEventFragment({ category: 'test' }); - }).toThrow(/Must specify success event and category\./u); - }); - - it('should update fragments state with new fragment', function () { - jest.useFakeTimers().setSystemTime(1730798301422); - - const metaMetricsController = getMetaMetricsController(); - const mockNewId = 'testid3'; - - metaMetricsController.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - uniqueIdentifier: mockNewId, - }); - - const resultFragment = metaMetricsController.state.fragments[mockNewId]; - - expect(resultFragment).toStrictEqual({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - id: mockNewId, - uniqueIdentifier: mockNewId, - lastUpdated: 1730798301422, - }); - - jest.useRealTimers(); - }); - - it('should track the initial event if provided', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - }); - const spy = jest.spyOn(segmentMock, 'track'); - const mockInitialEventName = 'Test Initial Event'; - - metaMetricsController.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - initialEvent: mockInitialEventName, - }); - - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('should not call track if no initialEvent was provided', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - }); - const spy = jest.spyOn(segmentMock, 'track'); - - metaMetricsController.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - }); - - expect(spy).toHaveBeenCalledTimes(0); - }); - - describe('when intialEvent is "Transaction Submitted" and a fragment exists before createEventFragment is called', function () { - it('should update existing fragment state with new fragment props', function () { - jest.useFakeTimers().setSystemTime(1730798302222); - - const metaMetricsController = getMetaMetricsController(); - const { id } = SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT; - - metaMetricsController.updateEventFragment( - SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT.id, - { - ...SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT, - }, - ); - metaMetricsController.createEventFragment({ - ...SAMPLE_PERSISTED_EVENT_NO_ID, - initialEvent: 'Transaction Submitted', - uniqueIdentifier: id, - }); - - const resultFragment = metaMetricsController.state.fragments[id]; - const expectedFragment = merge( - SAMPLE_TX_SUBMITTED_PARTIAL_FRAGMENT, - SAMPLE_PERSISTED_EVENT_NO_ID, - { - canDeleteIfAbandoned: false, - id, - initialEvent: 'Transaction Submitted', - uniqueIdentifier: id, - lastUpdated: 1730798302222, - }, - ); - - expect(resultFragment).toStrictEqual(expectedFragment); - - jest.useRealTimers(); - }); - }); - }); - - describe('updateEventFragment', function () { - beforeEach(function () { - jest.useFakeTimers().setSystemTime(1730798303333); - }); - afterEach(function () { - jest.useRealTimers(); - }); - - it('updates fragment with additional provided props', async function () { - const metaMetricsController = getMetaMetricsController(); - const MOCK_PROPS_TO_UPDATE = { - properties: { - test: 1, - }, - }; - - metaMetricsController.updateEventFragment( - SAMPLE_PERSISTED_EVENT.id, - MOCK_PROPS_TO_UPDATE, - ); - - const resultFragment = - metaMetricsController.state.fragments[SAMPLE_PERSISTED_EVENT.id]; - const expectedPartialFragment = { - ...SAMPLE_PERSISTED_EVENT, - ...MOCK_PROPS_TO_UPDATE, - lastUpdated: 1730798303333, - }; - expect(resultFragment).toStrictEqual(expectedPartialFragment); - }); - - it('throws error when no existing fragment exists', async function () { - const metaMetricsController = getMetaMetricsController(); - - const MOCK_NONEXISTING_ID = 'test-nonexistingid'; - - await expect(() => { - metaMetricsController.updateEventFragment(MOCK_NONEXISTING_ID, { - properties: { test: 1 }, - }); - }).toThrow(/Event fragment with id test-nonexistingid does not exist\./u); - }); - - describe('when id includes "transaction-submitted"', function () { - it('creates and stores new fragment props with canDeleteIfAbandoned set to true', function () { - const metaMetricsController = getMetaMetricsController(); - const MOCK_ID = 'transaction-submitted-1111'; - const MOCK_PROPS_TO_UPDATE = { - properties: { - test: 1, - }, - }; - - metaMetricsController.updateEventFragment( - MOCK_ID, - MOCK_PROPS_TO_UPDATE, - ); - - const resultFragment = metaMetricsController.state.fragments[MOCK_ID]; - const expectedPartialFragment = { - ...MOCK_PROPS_TO_UPDATE, - category: 'Transactions', - canDeleteIfAbandoned: true, - id: MOCK_ID, - lastUpdated: 1730798303333, - successEvent: 'Transaction Finalized', - }; - expect(resultFragment).toStrictEqual(expectedPartialFragment); - }); - }); - }); - - describe('generateMetaMetricsId', function () { - it('should generate an 0x prefixed hex string', function () { - const metaMetricsController = getMetaMetricsController(); - expect( - metaMetricsController.generateMetaMetricsId().startsWith('0x'), - ).toStrictEqual(true); - }); - }); - - describe('getMetaMetricsId', function () { - it('should generate or return the metametrics id', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: null, - }); - - // Starts off being empty. - expect(metaMetricsController.state.metaMetricsId).toStrictEqual(null); - - // Create a new metametrics id. - const clientMetaMetricsId = metaMetricsController.getMetaMetricsId(); - expect(clientMetaMetricsId.startsWith('0x')).toStrictEqual(true); - - // Return same metametrics id. - const sameMetaMetricsId = metaMetricsController.getMetaMetricsId(); - expect(clientMetaMetricsId).toStrictEqual(sameMetaMetricsId); - }); - }); - - describe('identify', function () { - it('should call segment.identify for valid traits if user is participating in metametrics', function () { - const spy = jest.spyOn(segmentMock, 'identify'); - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }); - metaMetricsController.identify({ - ...MOCK_TRAITS, - ...MOCK_INVALID_TRAITS, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - userId: TEST_META_METRICS_ID, - traits: MOCK_TRAITS, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should transform date type traits into ISO-8601 timestamp strings', function () { - const spy = jest.spyOn(segmentMock, 'identify'); - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }); - metaMetricsController.identify({ - test_date: new Date().toISOString(), - } as MetaMetricsUserTraits); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - userId: TEST_META_METRICS_ID, - traits: { - test_date: new Date().toISOString(), - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should not call segment.identify if user is not participating in metametrics', function () { - const spy = jest.spyOn(segmentMock, 'identify'); - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: false, - }); - metaMetricsController.identify(MOCK_TRAITS); - expect(spy).toHaveBeenCalledTimes(0); - }); - - it('should not call segment.identify if there are no valid traits to identify', function () { - const spy = jest.spyOn(segmentMock, 'identify'); - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - }); - metaMetricsController.identify(MOCK_INVALID_TRAITS); - expect(spy).toHaveBeenCalledTimes(0); - }); - }); - - describe('setParticipateInMetaMetrics', function () { - it('should update the value of participateInMetaMetrics', async function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: null, - metaMetricsId: null, - }); - expect( - metaMetricsController.state.participateInMetaMetrics, - ).toStrictEqual(null); - await metaMetricsController.setParticipateInMetaMetrics(true); - expect( - metaMetricsController.state.participateInMetaMetrics, - ).toStrictEqual(true); - await metaMetricsController.setParticipateInMetaMetrics(false); - expect( - metaMetricsController.state.participateInMetaMetrics, - ).toStrictEqual(false); - }); - it('should generate and update the metaMetricsId when set to true', async function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: null, - metaMetricsId: null, - }); - expect(metaMetricsController.state.metaMetricsId).toStrictEqual(null); - await metaMetricsController.setParticipateInMetaMetrics(true); - expect(typeof metaMetricsController.state.metaMetricsId).toStrictEqual( - 'string', - ); - }); - it('should not nullify the metaMetricsId when set to false', async function () { - const metaMetricsController = getMetaMetricsController(); - await metaMetricsController.setParticipateInMetaMetrics(false); - expect(metaMetricsController.state.metaMetricsId).toStrictEqual( - TEST_META_METRICS_ID, - ); - }); - it('should nullify the marketingCampaignCookieId when participateInMetaMetrics is toggled off', async function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - dataCollectionForMarketing: true, - marketingCampaignCookieId: TEST_GA_COOKIE_ID, - }); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(TEST_GA_COOKIE_ID); - await metaMetricsController.setParticipateInMetaMetrics(false); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(null); - }); - }); - - describe('submitEvent', function () { - it('should not track an event if user is not participating in metametrics', function () { - const spy = jest.spyOn(segmentMock, 'track'); - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: false, - }); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }); - expect(spy).toHaveBeenCalledTimes(0); - }); - - it('should track an event if user has not opted in, but isOptIn is true', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - }); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }, - { isOptIn: true }, - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: '1', - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should track an event during optin and allow for metaMetricsId override', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - }); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }, - { isOptIn: true, metaMetricsId: 'TESTID' }, - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: 'TESTID', - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: '1', - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should track a legacy event', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }, - { matomoEvent: true }, - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - legacy_event: true, - chain_id: '1', - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should track a non legacy event', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: '1', - }, - context: DEFAULT_TEST_CONTEXT, - userId: TEST_META_METRICS_ID, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should immediately flush queue if flushImmediately set to true', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'flush'); - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - }, - { flushImmediately: true }, - ); - expect(spy).not.toThrow(); - }); - - it('should throw if event or category not provided', async function () { - const metaMetricsController = getMetaMetricsController(); - - await expect( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error because we are testing the error case - metaMetricsController.submitEvent({ event: 'test' }), - ).rejects.toThrow(/Must specify event and category\./u); - - await expect( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error because we are testing the error case - metaMetricsController.submitEvent({ category: 'test' }), - ).rejects.toThrow(/Must specify event and category\./u); - }); - - it('should throw if provided sensitiveProperties, when excludeMetaMetricsId is true', async function () { - const metaMetricsController = getMetaMetricsController(); - await expect( - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }, - { excludeMetaMetricsId: true }, - ), - ).rejects.toThrow( - /sensitiveProperties was specified in an event payload that also set the excludeMetaMetricsId flag/u, - ); - }); - - it('should track sensitiveProperties in a separate, anonymous event', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_EVENT_PROPERTIES, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - }); - - describe('Change Signature XXX anonymous event names', function () { - // @ts-expect-error This function is missing from the Mocha type definitions - it.each([ - ['Signature Requested', 'Signature Requested Anon'], - ['Signature Rejected', 'Signature Rejected Anon'], - ['Signature Approved', 'Signature Approved Anon'], - ])( - 'should change "%s" anonymous event names to "%s"', - (eventType: string, anonEventType: string) => { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: eventType, - category: 'Unit Test', - properties: { ...DEFAULT_EVENT_PROPERTIES }, - sensitiveProperties: { foo: 'bar' }, - }); - - expect(spy).toHaveBeenCalledTimes(2); - - expect(spy.mock.calls[0][0]).toMatchObject({ - event: anonEventType, - properties: { foo: 'bar', ...DEFAULT_EVENT_PROPERTIES }, - }); - - expect(spy.mock.calls[1][0]).toMatchObject({ - event: eventType, - properties: { ...DEFAULT_EVENT_PROPERTIES }, - }); - }, - ); - }); - - describe('Change Transaction XXX anonymous event namnes', function () { - it('should change "Transaction Added" anonymous event names to "Transaction Added Anon"', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Transaction Added', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Added Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should change "Transaction Submitted" anonymous event names to "Transaction Added Anon"', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Transaction Submitted', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Submitted Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should change "Transaction Finalized" anonymous event names to "Transaction Added Anon"', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Transaction Finalized', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: `Transaction Finalized Anon`, - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - describe('trackPage', function () { - it('should track a page view', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'page'); - metaMetricsController.trackPage({ - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - params: undefined, - ...DEFAULT_PAGE_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should not track a page view if user is not participating in metametrics', function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: false, - }); - const spy = jest.spyOn(segmentMock, 'page'); - metaMetricsController.trackPage({ - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }); - expect(spy).toHaveBeenCalledTimes(0); - }); - - it('should track a page view if isOptInPath is true and user not yet opted in', function () { - const metaMetricsController = getMetaMetricsController({ - currentLocale: LOCALE, - participateInMetaMetrics: true, - onPreferencesStateChange: jest.fn(), - }); - const spy = jest.spyOn(segmentMock, 'page'); - metaMetricsController.trackPage( - { - name: 'home', - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_PAGE_PROPERTIES, - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('multiple trackPage call with same actionId should result in same messageId being sent to segment', function () { - const metaMetricsController = getMetaMetricsController({ - currentLocale: LOCALE, - participateInMetaMetrics: true, - onPreferencesStateChange: jest.fn(), - }); - const spy = jest.spyOn(segmentMock, 'page'); - metaMetricsController.trackPage( - { - name: 'home', - actionId: DUMMY_ACTION_ID, - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - metaMetricsController.trackPage( - { - name: 'home', - actionId: DUMMY_ACTION_ID, - environmentType: ENVIRONMENT_TYPE_BACKGROUND, - page: METAMETRICS_BACKGROUND_PAGE_OBJECT, - }, - { isOptInPath: true }, - ); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - name: 'home', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_PAGE_PROPERTIES, - messageId: DUMMY_ACTION_ID, - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - - describe('deterministic messageId', function () { - it('should use the actionId as messageId when provided', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: 'bar', - }, - actionId: '0x001', - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: 'bar', - }, - messageId: '0x001', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should append 0x000 to the actionId of anonymized event when tracking sensitiveProperties', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - actionId: '0x001', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: '0x001-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: DEFAULT_EVENT_PROPERTIES, - messageId: '0x001', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - - it('should use the uniqueIdentifier as messageId when provided', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: 'bar', - }, - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: 'bar', - }, - messageId: 'transaction-submitted-0000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should append 0x000 to the uniqueIdentifier of anonymized event when tracking sensitiveProperties', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - - it('should combine the uniqueIdentifier and actionId as messageId when both provided', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - properties: { chain_id: 'bar' }, - actionId: '0x001', - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: 'bar', - }, - messageId: 'transaction-submitted-0000-0x001', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - - it('should append 0x000 to the combined uniqueIdentifier and actionId of anonymized event when tracking sensitiveProperties', function () { - const metaMetricsController = getMetaMetricsController(); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent({ - event: 'Fake Event', - category: 'Unit Test', - sensitiveProperties: { foo: 'bar' }, - actionId: '0x001', - uniqueIdentifier: 'transaction-submitted-0000', - }); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - foo: 'bar', - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x001-0x000', - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - userId: TEST_META_METRICS_ID, - context: DEFAULT_TEST_CONTEXT, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - }, - messageId: 'transaction-submitted-0000-0x001', - timestamp: new Date(), - }, - spy.mock.calls[1][1], - ); - }); - }); - - describe('_buildUserTraitsObject', function () { - it('should return full user traits object on first call', function () { - const MOCK_ALL_TOKENS: TokensControllerState['allTokens'] = { - [toHex(1)]: { - '0x1235ce91d74254f29d4609f25932fe6d97bf4842': [ - { - address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', - }, - { - address: '0xabc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9', - }, - ] as Token[], - '0xe364b0f9d1879e53e8183055c9d7dd2b7375d86b': [ - { - address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', - }, - ] as Token[], - }, - [toHex(4)]: { - '0x1235ce91d74254f29d4609f25932fe6d97bf4842': [ - { - address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', - }, - { - address: '0x12317F958D2ee523a2206206994597C13D831ec7', - }, - ] as Token[], - }, - }; - - const metaMetricsController = getMetaMetricsController(); - const traits = metaMetricsController._buildUserTraitsObject({ - addressBook: { - [CHAIN_IDS.MAINNET]: { - '0x': { - address: '0x', - } as AddressBookEntry, - }, - [CHAIN_IDS.GOERLI]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x0': { - address: '0x0', - } as AddressBookEntry, - }, - }, - allNfts: { - '0xac706cE8A9BF27Afecf080fB298d0ee13cfb978A': { - [toHex(56)]: [ - { - address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', - tokenId: '100', - }, - { - address: '0xd2cea331e5f5d8ee9fb1055c297795937645de91', - tokenId: '101', - }, - { - address: '0x7488d2ce5deb26db021285b50b661d655eb3d3d9', - tokenId: '99', - }, - ] as Nft[], - }, - '0xe04AB39684A24D8D4124b114F3bd6FBEB779cacA': { - [toHex(59)]: [ - { - address: '0x63d646bc7380562376d5de205123a57b1718184d', - tokenId: '14', - }, - ] as Nft[], - }, - }, - allTokens: MOCK_ALL_TOKENS, - ...mockNetworkState( - { chainId: CHAIN_IDS.MAINNET }, - { chainId: CHAIN_IDS.GOERLI }, - { chainId: '0xaf' }, - ), - internalAccounts: { - accounts: { - mock1: {} as InternalAccount, - mock2: {} as InternalAccount, - }, - selectedAccount: 'mock1', - }, - ledgerTransportType: LedgerTransportTypes.webhid, - openSeaEnabled: true, - useNftDetection: false, - securityAlertsEnabled: true, - theme: 'default', - useTokenDetection: true, - ShowNativeTokenAsMainBalance: true, - security_providers: [], - names: { - [NameType.ETHEREUM_ADDRESS]: { - '0x123': { - '0x1': { - name: 'Test 1', - } as NameEntry, - '0x2': { - name: 'Test 2', - } as NameEntry, - '0x3': { - name: null, - } as NameEntry, - }, - '0x456': { - '0x1': { - name: 'Test 3', - } as NameEntry, - }, - '0x789': { - '0x1': { - name: null, - } as NameEntry, - }, - }, - }, - tokenSortConfig: { - key: 'token-sort-key', - order: 'dsc', - sortCallback: 'stringNumeric', - }, - participateInMetaMetrics: true, - currentCurrency: 'usd', - dataCollectionForMarketing: false, - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - custodyAccountDetails: {}, - ///: END:ONLY_INCLUDE_IF - }); - - expect(traits).toStrictEqual({ - [MetaMetricsUserTrait.AddressBookEntries]: 3, - [MetaMetricsUserTrait.InstallDateExt]: '', - [MetaMetricsUserTrait.LedgerConnectionType]: - LedgerTransportTypes.webhid, - [MetaMetricsUserTrait.NetworksAdded]: [ - CHAIN_IDS.MAINNET, - CHAIN_IDS.GOERLI, - '0xaf', - ], - [MetaMetricsUserTrait.NetworksWithoutTicker]: ['0xaf'], - [MetaMetricsUserTrait.NftAutodetectionEnabled]: false, - [MetaMetricsUserTrait.NumberOfAccounts]: 2, - [MetaMetricsUserTrait.NumberOfNftCollections]: 3, - [MetaMetricsUserTrait.NumberOfNfts]: 4, - [MetaMetricsUserTrait.NumberOfTokens]: 5, - [MetaMetricsUserTrait.OpenSeaApiEnabled]: true, - [MetaMetricsUserTrait.ThreeBoxEnabled]: false, - [MetaMetricsUserTrait.Theme]: 'default', - [MetaMetricsUserTrait.TokenDetectionEnabled]: true, - [MetaMetricsUserTrait.ShowNativeTokenAsMainBalance]: true, - [MetaMetricsUserTrait.CurrentCurrency]: 'usd', - [MetaMetricsUserTrait.HasMarketingConsent]: false, - [MetaMetricsUserTrait.SecurityProviders]: ['blockaid'], - [MetaMetricsUserTrait.IsMetricsOptedIn]: true, - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - [MetaMetricsUserTrait.MmiExtensionId]: 'testid', - [MetaMetricsUserTrait.MmiAccountAddress]: null, - [MetaMetricsUserTrait.MmiIsCustodian]: false, - ///: END:ONLY_INCLUDE_IF - ///: BEGIN:ONLY_INCLUDE_IF(petnames) - [MetaMetricsUserTrait.PetnameAddressCount]: 3, - ///: END:ONLY_INCLUDE_IF - [MetaMetricsUserTrait.TokenSortPreference]: 'token-sort-key', - }); - }); - - it('should return only changed traits object on subsequent calls', function () { - const metaMetricsController = getMetaMetricsController(); - const networkState = mockNetworkState( - { chainId: CHAIN_IDS.MAINNET }, - { chainId: CHAIN_IDS.GOERLI }, - ); - metaMetricsController._buildUserTraitsObject({ - addressBook: { - [CHAIN_IDS.MAINNET]: { - '0x': { - address: '0x', - } as AddressBookEntry, - }, - [CHAIN_IDS.GOERLI]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x0': { - address: '0x0', - } as AddressBookEntry, - }, - }, - allTokens: {}, - ...networkState, - ledgerTransportType: LedgerTransportTypes.webhid, - openSeaEnabled: true, - internalAccounts: { - accounts: { - mock1: {} as InternalAccount, - mock2: {} as InternalAccount, - }, - selectedAccount: 'mock1', - }, - useNftDetection: false, - theme: 'default', - useTokenDetection: true, - tokenSortConfig: { - key: 'token-sort-key', - order: 'dsc', - sortCallback: 'stringNumeric', - }, - ShowNativeTokenAsMainBalance: true, - allNfts: {}, - participateInMetaMetrics: true, - dataCollectionForMarketing: false, - securityAlertsEnabled: true, - names: { - ethereumAddress: {}, - }, - security_providers: ['blockaid'], - currentCurrency: 'usd', - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - custodyAccountDetails: {}, - ///: END:ONLY_INCLUDE_IF - }); - - const updatedTraits = metaMetricsController._buildUserTraitsObject({ - addressBook: { - [CHAIN_IDS.MAINNET]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x1': { - address: '0x1', - } as AddressBookEntry, - }, - [CHAIN_IDS.GOERLI]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x0': { - address: '0x0', - } as AddressBookEntry, - }, - }, - allTokens: { - [toHex(1)]: { - '0xabcde': [{ address: '0xtestAddress' } as Token], - }, - }, - ...networkState, - ledgerTransportType: LedgerTransportTypes.webhid, - openSeaEnabled: false, - internalAccounts: { - accounts: { - mock1: {} as InternalAccount, - mock2: {} as InternalAccount, - mock3: {} as InternalAccount, - }, - selectedAccount: 'mock1', - }, - useNftDetection: false, - theme: 'default', - useTokenDetection: true, - tokenSortConfig: { - key: 'token-sort-key', - order: 'dsc', - sortCallback: 'stringNumeric', - }, - ShowNativeTokenAsMainBalance: false, - names: { - ethereumAddress: {}, - }, - security_providers: ['blockaid'], - currentCurrency: 'usd', - allNfts: {}, - participateInMetaMetrics: true, - dataCollectionForMarketing: false, - securityAlertsEnabled: true, - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - custodyAccountDetails: {}, - ///: END:ONLY_INCLUDE_IF - }); - - expect(updatedTraits).toStrictEqual({ - [MetaMetricsUserTrait.AddressBookEntries]: 4, - [MetaMetricsUserTrait.NumberOfAccounts]: 3, - [MetaMetricsUserTrait.NumberOfTokens]: 1, - [MetaMetricsUserTrait.OpenSeaApiEnabled]: false, - [MetaMetricsUserTrait.ShowNativeTokenAsMainBalance]: false, - }); - }); - - it('should return null if no traits changed', function () { - const metaMetricsController = getMetaMetricsController(); - const networkState = mockNetworkState( - { chainId: CHAIN_IDS.MAINNET }, - { chainId: CHAIN_IDS.GOERLI }, - ); - metaMetricsController._buildUserTraitsObject({ - addressBook: { - [CHAIN_IDS.MAINNET]: { - '0x': { - address: '0x', - } as AddressBookEntry, - }, - [CHAIN_IDS.GOERLI]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x0': { - address: '0x0', - } as AddressBookEntry, - }, - }, - allTokens: {}, - ...networkState, - ledgerTransportType: LedgerTransportTypes.webhid, - openSeaEnabled: true, - internalAccounts: { - accounts: { - mock1: {} as InternalAccount, - mock2: {} as InternalAccount, - }, - selectedAccount: 'mock1', - }, - useNftDetection: true, - theme: 'default', - useTokenDetection: true, - tokenSortConfig: { - key: 'token-sort-key', - order: 'dsc', - sortCallback: 'stringNumeric', - }, - ShowNativeTokenAsMainBalance: true, - allNfts: {}, - names: { - ethereumAddress: {}, - }, - participateInMetaMetrics: true, - dataCollectionForMarketing: false, - securityAlertsEnabled: true, - security_providers: ['blockaid'], - currentCurrency: 'usd', - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - custodyAccountDetails: {}, - ///: END:ONLY_INCLUDE_IF - }); - - const updatedTraits = metaMetricsController._buildUserTraitsObject({ - addressBook: { - [CHAIN_IDS.MAINNET]: { - '0x': { - address: '0x', - } as AddressBookEntry, - }, - [CHAIN_IDS.GOERLI]: { - '0x': { - address: '0x', - } as AddressBookEntry, - '0x0': { address: '0x0' } as AddressBookEntry, - }, - }, - allTokens: {}, - ...networkState, - ledgerTransportType: LedgerTransportTypes.webhid, - openSeaEnabled: true, - internalAccounts: { - accounts: { - mock1: {} as InternalAccount, - mock2: {} as InternalAccount, - }, - selectedAccount: 'mock1', - }, - useNftDetection: true, - theme: 'default', - useTokenDetection: true, - tokenSortConfig: { - key: 'token-sort-key', - order: 'dsc', - sortCallback: 'stringNumeric', - }, - ShowNativeTokenAsMainBalance: true, - allNfts: {}, - participateInMetaMetrics: true, - dataCollectionForMarketing: false, - names: { - ethereumAddress: {}, - }, - securityAlertsEnabled: true, - security_providers: ['blockaid'], - currentCurrency: 'usd', - ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) - custodyAccountDetails: {}, - ///: END:ONLY_INCLUDE_IF - }); - expect(updatedTraits).toStrictEqual(null); - }); - }); - - describe('submitting segmentApiCalls to segment SDK', function () { - it('should add event to store when submitting to SDK', function () { - const metaMetricsController = getMetaMetricsController({}); - metaMetricsController.trackPage({}, { isOptInPath: true }); - const { segmentApiCalls } = metaMetricsController.store.getState(); - expect(Object.keys(segmentApiCalls).length > 0).toStrictEqual(true); - }); - - it('should remove event from store when callback is invoked', function () { - const segmentInstance = createSegmentMock(2); - const stubFn = (...args: unknown[]) => { - const cb = args[1] as () => void; - cb(); - }; - jest.spyOn(segmentInstance, 'track').mockImplementation(stubFn); - jest.spyOn(segmentInstance, 'page').mockImplementation(stubFn); - - const metaMetricsController = getMetaMetricsController({ - segment: segmentInstance, - }); - metaMetricsController.trackPage({}, { isOptInPath: true }); - const { segmentApiCalls } = metaMetricsController.store.getState(); - expect(Object.keys(segmentApiCalls).length === 0).toStrictEqual(true); - }); - }); - describe('setMarketingCampaignCookieId', function () { - it('should update marketingCampaignCookieId in the context when cookieId is available', async function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - dataCollectionForMarketing: true, - }); - metaMetricsController.setMarketingCampaignCookieId(TEST_GA_COOKIE_ID); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(TEST_GA_COOKIE_ID); - const spy = jest.spyOn(segmentMock, 'track'); - metaMetricsController.submitEvent( - { - event: 'Fake Event', - category: 'Unit Test', - properties: { - chain_id: '1', - }, - }, - { isOptIn: true }, - ); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - { - event: 'Fake Event', - anonymousId: METAMETRICS_ANONYMOUS_ID, - context: { - ...DEFAULT_TEST_CONTEXT, - marketingCampaignCookieId: TEST_GA_COOKIE_ID, - }, - properties: { - ...DEFAULT_EVENT_PROPERTIES, - chain_id: '1', - }, - messageId: Utils.generateRandomId(), - timestamp: new Date(), - }, - spy.mock.calls[0][1], - ); - }); - }); - describe('setDataCollectionForMarketing', function () { - it('should nullify the marketingCampaignCookieId when Data collection for marketing is toggled off', async function () { - const metaMetricsController = getMetaMetricsController({ - participateInMetaMetrics: true, - metaMetricsId: TEST_META_METRICS_ID, - dataCollectionForMarketing: true, - marketingCampaignCookieId: TEST_GA_COOKIE_ID, - }); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(TEST_GA_COOKIE_ID); - await metaMetricsController.setDataCollectionForMarketing(false); - expect( - metaMetricsController.state.marketingCampaignCookieId, - ).toStrictEqual(null); - }); - }); - afterEach(function () { - // flush the queues manually after each test - segmentMock.flush(); - jest.useRealTimers(); - jest.restoreAllMocks(); - }); -}); diff --git a/app/scripts/controllers/mmi-controller.test.ts b/app/scripts/controllers/mmi-controller.test.ts index 479c4cb0f14d..71c8329b3dcd 100644 --- a/app/scripts/controllers/mmi-controller.test.ts +++ b/app/scripts/controllers/mmi-controller.test.ts @@ -21,11 +21,11 @@ import { MMIController, AllowedActions } from './mmi-controller'; import { AppStateController } from './app-state-controller'; import { ControllerMessenger } from '@metamask/base-controller'; import { mmiKeyringBuilderFactory } from '../mmi-keyring-builder-factory'; -import MetaMetricsController from './metametrics'; import { ETH_EOA_METHODS } from '../../../shared/constants/eth-methods'; import { mockNetworkState } from '../../../test/stub/networks'; import { InfuraNetworkType } from '@metamask/controller-utils'; import { API_REQUEST_LOG_EVENT } from '@metamask-institutional/sdk'; +import { getDefaultPreferencesControllerState } from './preferences-controller'; jest.mock('@metamask-institutional/portfolio-dashboard', () => ({ handleMmiPortfolio: jest.fn(), @@ -90,7 +90,6 @@ describe('MMIController', function () { controllerMessenger, accountsController, keyringController, - metaMetricsController, custodyController, mmiControllerMessenger; @@ -106,9 +105,9 @@ describe('MMIController', function () { }; const controllerMessenger = new ControllerMessenger< - AllowedActions, - never - >(); + AllowedActions, + never + >(); accountsController = new AccountsController({ messenger: controllerMessenger.getRestricted({ @@ -207,18 +206,18 @@ describe('MMIController', function () { }, }); - metaMetricsController = new MetaMetricsController({ - preferencesControllerState: { - currentLocale: 'en' - }, - onPreferencesStateChange: jest.fn(), - getCurrentChainId: jest.fn(), - onNetworkDidChange: jest.fn(), - }); + controllerMessenger.registerActionHandler( + 'MetaMetricsController:getState', + () => ({ + metaMetricsId: mockMetaMetricsId, + }), + ); controllerMessenger.registerActionHandler( 'NetworkController:getState', - jest.fn().mockReturnValue(mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA })), + jest + .fn() + .mockReturnValue(mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA })), ); controllerMessenger.registerActionHandler( @@ -231,7 +230,7 @@ describe('MMIController', function () { jest.fn().mockReturnValue({ configuration: { chainId: CHAIN_IDS.SEPOLIA, - } + }, }), ); @@ -248,10 +247,11 @@ describe('MMIController', function () { 'AccountsController:listAccounts', 'AccountsController:getSelectedAccount', 'AccountsController:setSelectedAccount', + 'MetaMetricsController:getState', 'NetworkController:getState', 'NetworkController:setActiveNetwork', 'NetworkController:getNetworkClientById', - 'NetworkController:getNetworkConfigurationByChainId' + 'NetworkController:getNetworkConfigurationByChainId', ], }); @@ -282,12 +282,11 @@ describe('MMIController', function () { preferences: { autoLockTimeLimit: 0, }, - }) - } + }), + }, }), permissionController, custodyController, - metaMetricsController, custodianEventHandlerFactory: jest.fn(), getTransactions: jest.fn(), updateTransactionHash: jest.fn(), @@ -302,10 +301,6 @@ describe('MMIController', function () { mmiController.getState = jest.fn(); mmiController.captureException = jest.fn(); mmiController.accountTrackerController = { syncWithAddresses: jest.fn() }; - - jest.spyOn(metaMetricsController.store, 'getState').mockReturnValue({ - metaMetricsId: mockMetaMetricsId, - }); }); afterEach(() => { @@ -391,24 +386,30 @@ describe('MMIController', function () { getSupportedChains: jest.fn().mockResolvedValue({}), }; - mmiController.addKeyringIfNotExists = jest.fn().mockResolvedValue(mockKeyring); - mmiController.custodyController.getAllCustodyTypes = jest.fn().mockReturnValue(['mock-custody-type']); + mmiController.addKeyringIfNotExists = jest + .fn() + .mockResolvedValue(mockKeyring); + mmiController.custodyController.getAllCustodyTypes = jest + .fn() + .mockReturnValue(['mock-custody-type']); mmiController.logAndStoreApiRequest = jest.fn(); await mmiController.onSubmitPassword(); expect(mockKeyring.on).toHaveBeenCalledWith( API_REQUEST_LOG_EVENT, - expect.any(Function) + expect.any(Function), ); const mockLogData = { someKey: 'someValue' }; const apiRequestLogEventHandler = mockKeyring.on.mock.calls.find( - call => call[0] === API_REQUEST_LOG_EVENT + (call) => call[0] === API_REQUEST_LOG_EVENT, )[1]; apiRequestLogEventHandler(mockLogData); - expect(mmiController.logAndStoreApiRequest).toHaveBeenCalledWith(mockLogData); + expect(mmiController.logAndStoreApiRequest).toHaveBeenCalledWith( + mockLogData, + ); }); }); @@ -489,29 +490,41 @@ describe('MMIController', function () { getStatusMap: jest.fn(), }; - mmiController.addKeyringIfNotExists = jest.fn().mockResolvedValue(mockKeyring); - mmiController.keyringController.getAccounts = jest.fn().mockResolvedValue(['0x2']); - mmiController.keyringController.addNewAccountForKeyring = jest.fn().mockResolvedValue('0x3'); + mmiController.addKeyringIfNotExists = jest + .fn() + .mockResolvedValue(mockKeyring); + mmiController.keyringController.getAccounts = jest + .fn() + .mockResolvedValue(['0x2']); + mmiController.keyringController.addNewAccountForKeyring = jest + .fn() + .mockResolvedValue('0x3'); mmiController.custodyController.setAccountDetails = jest.fn(); mmiController.accountTrackerController.syncWithAddresses = jest.fn(); mmiController.storeCustodianSupportedChains = jest.fn(); mmiController.custodyController.storeCustodyStatusMap = jest.fn(); mmiController.logAndStoreApiRequest = jest.fn(); - await mmiController.connectCustodyAddresses(custodianType, custodianName, accounts); + await mmiController.connectCustodyAddresses( + custodianType, + custodianName, + accounts, + ); expect(mockKeyring.on).toHaveBeenCalledWith( API_REQUEST_LOG_EVENT, - expect.any(Function) + expect.any(Function), ); const mockLogData = { someKey: 'someValue' }; const apiRequestLogEventHandler = mockKeyring.on.mock.calls.find( - call => call[0] === API_REQUEST_LOG_EVENT + (call) => call[0] === API_REQUEST_LOG_EVENT, )[1]; apiRequestLogEventHandler(mockLogData); - expect(mmiController.logAndStoreApiRequest).toHaveBeenCalledWith(mockLogData); + expect(mmiController.logAndStoreApiRequest).toHaveBeenCalledWith( + mockLogData, + ); }); }); @@ -538,7 +551,9 @@ describe('MMIController', function () { CUSTODIAN_TYPES['CUSTODIAN-TYPE'] = { keyringClass: { type: 'mock-keyring-class' }, }; - jest.spyOn(mmiControllerMessenger, 'call').mockReturnValue({ address: '0x1' }); + jest + .spyOn(mmiControllerMessenger, 'call') + .mockReturnValue({ address: '0x1' }); mmiController.custodyController.getCustodyTypeByAddress = jest .fn() .mockReturnValue('custodian-type'); @@ -657,7 +672,9 @@ describe('MMIController', function () { mmiController.custodyController.getAccountDetails = jest .fn() .mockReturnValue({}); - jest.spyOn(mmiControllerMessenger, 'call').mockReturnValue([mockAccount, mockAccount2]); + jest + .spyOn(mmiControllerMessenger, 'call') + .mockReturnValue([mockAccount, mockAccount2]); mmiController.mmiConfigurationController.store.getState = jest .fn() .mockReturnValue({ @@ -890,11 +907,15 @@ describe('MMIController', function () { const mockLogData = { someKey: 'someValue' }; const mockSanitizedLogs = { sanitizedKey: 'sanitizedValue' }; - mmiController.custodyController.sanitizeAndLogApiCall = jest.fn().mockResolvedValue(mockSanitizedLogs); + mmiController.custodyController.sanitizeAndLogApiCall = jest + .fn() + .mockResolvedValue(mockSanitizedLogs); const result = await mmiController.logAndStoreApiRequest(mockLogData); - expect(mmiController.custodyController.sanitizeAndLogApiCall).toHaveBeenCalledWith(mockLogData); + expect( + mmiController.custodyController.sanitizeAndLogApiCall, + ).toHaveBeenCalledWith(mockLogData); expect(result).toEqual(mockSanitizedLogs); }); @@ -902,9 +923,13 @@ describe('MMIController', function () { const mockLogData = { someKey: 'someValue' }; const mockError = new Error('Sanitize error'); - mmiController.custodyController.sanitizeAndLogApiCall = jest.fn().mockRejectedValue(mockError); + mmiController.custodyController.sanitizeAndLogApiCall = jest + .fn() + .mockRejectedValue(mockError); - await expect(mmiController.logAndStoreApiRequest(mockLogData)).rejects.toThrow('Sanitize error'); + await expect( + mmiController.logAndStoreApiRequest(mockLogData), + ).rejects.toThrow('Sanitize error'); }); }); }); diff --git a/app/scripts/controllers/mmi-controller.ts b/app/scripts/controllers/mmi-controller.ts index ac8de8b76656..9f2701b2c0b9 100644 --- a/app/scripts/controllers/mmi-controller.ts +++ b/app/scripts/controllers/mmi-controller.ts @@ -24,9 +24,9 @@ import { NetworkState } from '@metamask/network-controller'; import { MessageParamsPersonal, MessageParamsTyped, + OriginalRequest, SignatureController, } from '@metamask/signature-controller'; -import { OriginalRequest } from '@metamask/message-manager'; import { InternalAccount } from '@metamask/keyring-api'; import { toHex } from '@metamask/controller-utils'; import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; @@ -42,7 +42,6 @@ import { ConnectionRequest, MMIControllerMessenger, } from '../../../shared/constants/mmi-controller'; -import MetaMetricsController from './metametrics'; import { getPermissionBackgroundApiMethods } from './permissions'; import AccountTrackerController from './account-tracker-controller'; import { AppStateController } from './app-state-controller'; @@ -85,8 +84,6 @@ export class MMIController { private accountTrackerController: AccountTrackerController; - private metaMetricsController: MetaMetricsController; - #networkControllerState: NetworkState; // TODO: Replace `any` with type @@ -141,7 +138,6 @@ export class MMIController { this.getState = opts.getState; this.getPendingNonce = opts.getPendingNonce; this.accountTrackerController = opts.accountTrackerController; - this.metaMetricsController = opts.metaMetricsController; this.permissionController = opts.permissionController; this.signatureController = opts.signatureController; this.platform = opts.platform; @@ -767,7 +763,9 @@ export class MMIController { name: internalAccount.metadata.name, }; }); - const { metaMetricsId } = this.metaMetricsController.store.getState(); + const { metaMetricsId } = this.messagingSystem.call( + 'MetaMetricsController:getState', + ); const getAccountDetails = (address: string) => this.custodyController.getAccountDetails(address); const extensionId = this.extension.runtime.id; diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.js index cb57c681649f..5ca2374db05b 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.js @@ -27,7 +27,7 @@ import { } from '../../../ui/helpers/utils/metrics'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths -import { REDESIGN_APPROVAL_TYPES } from '../../../ui/pages/confirmations/utils/confirm'; +import { shouldUseRedesignForSignatures } from '../../../shared/lib/confirmation.utils'; import { getSnapAndHardwareInfoForMetrics } from './snap-keyring/metrics'; /** @@ -189,8 +189,6 @@ function finalizeSignatureFragment( * signature requests * * @param {object} opts - options for the rpc method tracking middleware - * @param {Function} opts.getMetricsState - get the state of - * MetaMetricsController * @param {number} [opts.rateLimitTimeout] - time, in milliseconds, to wait before * allowing another set of events to be tracked for methods rate limited by timeout. * @param {number} [opts.rateLimitSamplePercent] - percentage, in decimal, of events @@ -198,6 +196,7 @@ function finalizeSignatureFragment( * @param {Function} opts.getAccountType * @param {Function} opts.getDeviceModel * @param {Function} opts.isConfirmationRedesignEnabled + * @param {Function} opts.isRedesignedConfirmationsDeveloperEnabled * @param {RestrictedControllerMessenger} opts.snapAndHardwareMessenger * @param {number} [opts.globalRateLimitTimeout] - time, in milliseconds, of the sliding * time window that should limit the number of method calls tracked to globalRateLimitMaxAmount. @@ -209,7 +208,6 @@ function finalizeSignatureFragment( */ export default function createRPCMethodTrackingMiddleware({ - getMetricsState, rateLimitTimeout = 60 * 5 * 1000, // 5 minutes rateLimitSamplePercent = 0.001, // 0.1% globalRateLimitTimeout = 60 * 5 * 1000, // 5 minutes @@ -217,6 +215,7 @@ export default function createRPCMethodTrackingMiddleware({ getAccountType, getDeviceModel, isConfirmationRedesignEnabled, + isRedesignedConfirmationsDeveloperEnabled, snapAndHardwareMessenger, appStateController, metaMetricsController, @@ -256,7 +255,7 @@ export default function createRPCMethodTrackingMiddleware({ // anything. This is extra redundancy because this value is checked in // the metametrics controller's trackEvent method as well. const userParticipatingInMetaMetrics = - getMetricsState().participateInMetaMetrics === true; + metaMetricsController.state.participateInMetaMetrics === true; // Get the event type, each of which has APPROVED, REJECTED and REQUESTED // keys for the various events in the flow. @@ -318,13 +317,15 @@ export default function createRPCMethodTrackingMiddleware({ req.securityAlertResponse.description; } - const isConfirmationRedesign = - isConfirmationRedesignEnabled() && - REDESIGN_APPROVAL_TYPES.find( - (type) => type === MESSAGE_TYPE_TO_APPROVAL_TYPE[method], - ); - - if (isConfirmationRedesign) { + if ( + shouldUseRedesignForSignatures({ + approvalType: MESSAGE_TYPE_TO_APPROVAL_TYPE[method], + isRedesignedSignaturesUserSettingEnabled: + isConfirmationRedesignEnabled(), + isRedesignedConfirmationsDeveloperEnabled: + isRedesignedConfirmationsDeveloperEnabled(), + }) + ) { eventProperties.ui_customizations = [ ...(eventProperties.ui_customizations || []), MetaMetricsEventUiCustomization.RedesignedConfirmation, diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index 244a995bf5f7..1949ca7f876e 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -1,7 +1,8 @@ import { errorCodes } from '@metamask/rpc-errors'; import { detectSIWE } from '@metamask/controller-utils'; +import { ControllerMessenger } from '@metamask/base-controller'; -import MetaMetricsController from '../controllers/metametrics'; +import MetaMetricsController from '../controllers/metametrics-controller'; import { MESSAGE_TYPE } from '../../../shared/constants/app'; import { MetaMetricsEventCategory, @@ -17,15 +18,13 @@ import { permitSignatureMsg, orderSignatureMsg, } from '../../../test/data/confirmations/typed_sign'; +import { getDefaultPreferencesControllerState } from '../controllers/preferences-controller'; import { createSegmentMock } from './segment'; import createRPCMethodTrackingMiddleware from './createRPCMethodTrackingMiddleware'; const MOCK_ID = '123'; const expectedUniqueIdentifier = `signature-${MOCK_ID}`; -const metricsState = { participateInMetaMetrics: null }; -const getMetricsState = () => metricsState; - const expectedMetametricsEventUndefinedProps = { actionId: undefined, currency: undefined, @@ -54,23 +53,54 @@ const appStateController = { }, }; -const metaMetricsController = new MetaMetricsController({ - segment: createSegmentMock(2, 10000), - getCurrentChainId: () => '0x1338', - onNetworkDidChange: jest.fn(), - preferencesControllerState: { +const controllerMessenger = new ControllerMessenger(); + +controllerMessenger.registerActionHandler( + 'PreferencesController:getState', + () => ({ + ...getDefaultPreferencesControllerState(), currentLocale: 'en_US', - preferences: {}, - }, - onPreferencesStateChange: jest.fn(), - version: '0.0.1', - environment: 'test', - initState: { - participateInMetaMetrics: true, + }), +); + +controllerMessenger.registerActionHandler( + 'NetworkController:getState', + jest.fn().mockReturnValue({ + selectedNetworkClientId: 'selectedNetworkClientId', + }), +); + +controllerMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + jest.fn().mockReturnValue({ + configuration: { + chainId: '0x1338', + }, + }), +); + +const metaMetricsController = new MetaMetricsController({ + state: { + participateInMetaMetrics: null, metaMetricsId: '0xabc', fragments: {}, events: {}, }, + messenger: controllerMessenger.getRestricted({ + name: 'MetaMetricsController', + allowedActions: [ + 'PreferencesController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'NetworkController:networkDidChange', + ], + }), + segment: createSegmentMock(2), + version: '0.0.1', + environment: 'test', extension: { runtime: { id: 'testid', @@ -81,7 +111,6 @@ const metaMetricsController = new MetaMetricsController({ const createHandler = (opts) => createRPCMethodTrackingMiddleware({ - getMetricsState, rateLimitTimeout: 1000, rateLimitSamplePercent: 0.1, globalRateLimitTimeout: 0, @@ -89,6 +118,7 @@ const createHandler = (opts) => appStateController, metaMetricsController, isConfirmationRedesignEnabled: () => false, + isRedesignedConfirmationsDeveloperEnabled: () => false, ...opts, }); @@ -142,7 +172,7 @@ describe('createRPCMethodTrackingMiddleware', () => { }); afterEach(() => { jest.resetAllMocks(); - metricsState.participateInMetaMetrics = null; + metaMetricsController.setParticipateInMetaMetrics(null); }); describe('before participateInMetaMetrics is set', () => { @@ -166,7 +196,7 @@ describe('createRPCMethodTrackingMiddleware', () => { describe('participateInMetaMetrics is set to false', () => { beforeEach(() => { - metricsState.participateInMetaMetrics = false; + metaMetricsController.setParticipateInMetaMetrics(false); }); it('should not track an event for a signature request', async () => { @@ -188,8 +218,20 @@ describe('createRPCMethodTrackingMiddleware', () => { }); describe('participateInMetaMetrics is set to true', () => { + const originalEnableConfirmationRedesign = + process.env.ENABLE_CONFIRMATION_REDESIGN; + beforeEach(() => { - metricsState.participateInMetaMetrics = true; + metaMetricsController.setParticipateInMetaMetrics(true); + }); + + beforeAll(() => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterAll(() => { + process.env.ENABLE_CONFIRMATION_REDESIGN = + originalEnableConfirmationRedesign; }); it(`should immediately track a ${MetaMetricsEventName.SignatureRequested} event`, async () => { diff --git a/app/scripts/lib/ppom/ppom-middleware.test.ts b/app/scripts/lib/ppom/ppom-middleware.test.ts index 8977c00aa3d7..16317fef0380 100644 --- a/app/scripts/lib/ppom/ppom-middleware.test.ts +++ b/app/scripts/lib/ppom/ppom-middleware.test.ts @@ -7,7 +7,6 @@ import { BlockaidReason, BlockaidResultType, } from '../../../../shared/constants/security-provider'; -import { flushPromises } from '../../../../test/lib/timer-helpers'; import { mockNetworkState } from '../../../../test/stub/networks'; import { createPPOMMiddleware, PPOMMiddlewareRequest } from './ppom-middleware'; import { @@ -105,7 +104,6 @@ const createMiddleware = ( }; describe('PPOMMiddleware', () => { - const validateRequestWithPPOMMock = jest.mocked(validateRequestWithPPOM); const generateSecurityAlertIdMock = jest.mocked(generateSecurityAlertId); const handlePPOMErrorMock = jest.mocked(handlePPOMError); const isChainSupportedMock = jest.mocked(isChainSupported); @@ -114,7 +112,6 @@ describe('PPOMMiddleware', () => { beforeEach(() => { jest.resetAllMocks(); - validateRequestWithPPOMMock.mockResolvedValue(SECURITY_ALERT_RESPONSE_MOCK); generateSecurityAlertIdMock.mockReturnValue(SECURITY_ALERT_ID_MOCK); handlePPOMErrorMock.mockReturnValue(SECURITY_ALERT_RESPONSE_MOCK); isChainSupportedMock.mockResolvedValue(true); @@ -129,38 +126,13 @@ describe('PPOMMiddleware', () => { }; }); - it('updates alert response after validating request', async () => { + it('adds checking chain response to confirmation requests while validation is in progress', async () => { const updateSecurityAlertResponse = jest.fn(); const middlewareFunction = createMiddleware({ updateSecurityAlertResponse, }); - const req = { - ...REQUEST_MOCK, - method: 'eth_sendTransaction', - securityAlertResponse: undefined, - }; - - await middlewareFunction( - req, - { ...JsonRpcResponseStruct.TYPE }, - () => undefined, - ); - - await flushPromises(); - - expect(updateSecurityAlertResponse).toHaveBeenCalledTimes(1); - expect(updateSecurityAlertResponse).toHaveBeenCalledWith( - req.method, - SECURITY_ALERT_ID_MOCK, - SECURITY_ALERT_RESPONSE_MOCK, - ); - }); - - it('adds loading response to confirmation requests while validation is in progress', async () => { - const middlewareFunction = createMiddleware(); - const req: PPOMMiddlewareRequest<(string | { to: string })[]> = { ...REQUEST_MOCK, method: 'eth_sendTransaction', @@ -173,7 +145,9 @@ describe('PPOMMiddleware', () => { () => undefined, ); - expect(req.securityAlertResponse?.reason).toBe(BlockaidReason.inProgress); + expect(req.securityAlertResponse?.reason).toBe( + BlockaidReason.checkingChain, + ); expect(req.securityAlertResponse?.result_type).toBe( BlockaidResultType.Loading, ); @@ -197,50 +171,6 @@ describe('PPOMMiddleware', () => { expect(validateRequestWithPPOM).not.toHaveBeenCalled(); }); - it('does not do validation if unable to get the chainId from the network provider config', async () => { - isChainSupportedMock.mockResolvedValue(false); - const middlewareFunction = createMiddleware({ - chainId: null, - }); - - const req = { - ...REQUEST_MOCK, - method: 'eth_sendTransaction', - securityAlertResponse: undefined, - }; - - await middlewareFunction( - req, - { ...JsonRpcResponseStruct.TYPE }, - () => undefined, - ); - - expect(req.securityAlertResponse).toBeUndefined(); - expect(validateRequestWithPPOM).not.toHaveBeenCalled(); - }); - - it('does not do validation if user is not on a supported network', async () => { - isChainSupportedMock.mockResolvedValue(false); - const middlewareFunction = createMiddleware({ - chainId: '0x2', - }); - - const req = { - ...REQUEST_MOCK, - method: 'eth_sendTransaction', - securityAlertResponse: undefined, - }; - - await middlewareFunction( - req, - { ...JsonRpcResponseStruct.TYPE }, - () => undefined, - ); - - expect(req.securityAlertResponse).toBeUndefined(); - expect(validateRequestWithPPOM).not.toHaveBeenCalled(); - }); - it('does not do validation when request is not for confirmation method', async () => { const middlewareFunction = createMiddleware(); diff --git a/app/scripts/lib/ppom/ppom-middleware.ts b/app/scripts/lib/ppom/ppom-middleware.ts index ebfdbe3f04d7..44c7a8a965c4 100644 --- a/app/scripts/lib/ppom/ppom-middleware.ts +++ b/app/scripts/lib/ppom/ppom-middleware.ts @@ -13,17 +13,16 @@ import { MESSAGE_TYPE } from '../../../../shared/constants/app'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import { PreferencesController } from '../../controllers/preferences-controller'; import { AppStateController } from '../../controllers/app-state-controller'; -import { LOADING_SECURITY_ALERT_RESPONSE } from '../../../../shared/constants/security-provider'; +import { SECURITY_ALERT_RESPONSE_CHECKING_CHAIN } from '../../../../shared/constants/security-provider'; // eslint-disable-next-line import/no-restricted-paths import { getProviderConfig } from '../../../../ui/ducks/metamask/metamask'; import { trace, TraceContext, TraceName } from '../../../../shared/lib/trace'; import { generateSecurityAlertId, handlePPOMError, - isChainSupported, validateRequestWithPPOM, } from './ppom-util'; -import { SecurityAlertResponse } from './types'; +import { SecurityAlertResponse, UpdateSecurityAlertResponse } from './types'; const CONFIRMATION_METHODS = Object.freeze([ 'eth_sendRawTransaction', @@ -64,11 +63,7 @@ export function createPPOMMiddleware< networkController: NetworkController, appStateController: AppStateController, accountsController: AccountsController, - updateSecurityAlertResponse: ( - method: string, - signatureAlertId: string, - securityAlertResponse: SecurityAlertResponse, - ) => void, + updateSecurityAlertResponse: UpdateSecurityAlertResponse, ) { return async ( req: PPOMMiddlewareRequest, @@ -88,9 +83,7 @@ export function createPPOMMiddleware< if ( !securityAlertsEnabled || - !CONFIRMATION_METHODS.includes(req.method) || - // Do not move this call above this check because it will result in unnecessary calls - !(await isChainSupported(chainId)) + !CONFIRMATION_METHODS.includes(req.method) ) { return; } @@ -122,27 +115,22 @@ export function createPPOMMiddleware< request: req, securityAlertId, chainId, - }).then((securityAlertResponse) => { - updateSecurityAlertResponse( - req.method, - securityAlertId, - securityAlertResponse, - ); + updateSecurityAlertResponse, }), ); - const loadingSecurityAlertResponse: SecurityAlertResponse = { - ...LOADING_SECURITY_ALERT_RESPONSE, + const securityAlertResponseCheckingChain: SecurityAlertResponse = { + ...SECURITY_ALERT_RESPONSE_CHECKING_CHAIN, securityAlertId, }; if (SIGNING_METHODS.includes(req.method)) { appStateController.addSignatureSecurityAlertResponse( - loadingSecurityAlertResponse, + securityAlertResponseCheckingChain, ); } - req.securityAlertResponse = loadingSecurityAlertResponse; + req.securityAlertResponse = securityAlertResponseCheckingChain; } catch (error) { req.securityAlertResponse = handlePPOMError( error, diff --git a/app/scripts/lib/ppom/ppom-util.test.ts b/app/scripts/lib/ppom/ppom-util.test.ts index ea62c3b88533..8acb6dd9788c 100644 --- a/app/scripts/lib/ppom/ppom-util.test.ts +++ b/app/scripts/lib/ppom/ppom-util.test.ts @@ -10,9 +10,12 @@ import { SignatureController, SignatureRequest, } from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; import { BlockaidReason, BlockaidResultType, + LOADING_SECURITY_ALERT_RESPONSE, + SECURITY_ALERT_RESPONSE_CHAIN_NOT_SUPPORTED, SecurityAlertSource, } from '../../../../shared/constants/security-provider'; import { AppStateController } from '../../controllers/app-state-controller'; @@ -32,7 +35,7 @@ jest.mock('@metamask/transaction-controller', () => ({ const SECURITY_ALERT_ID_MOCK = '1234-5678'; const TRANSACTION_ID_MOCK = '123'; -const CHAIN_ID_MOCK = '0x1'; +const CHAIN_ID_MOCK = '0x1' as Hex; const REQUEST_MOCK = { method: 'eth_signTypedData_v4', @@ -45,6 +48,7 @@ const SECURITY_ALERT_RESPONSE_MOCK: SecurityAlertResponse = { result_type: 'success', reason: 'success', source: SecurityAlertSource.Local, + securityAlertId: SECURITY_ALERT_ID_MOCK, }; const TRANSACTION_PARAMS_MOCK_1: TransactionParams = { @@ -110,6 +114,15 @@ describe('PPOM Utils', () => { ); let isSecurityAlertsEnabledMock: jest.SpyInstance; + const updateSecurityAlertResponseMock = jest.fn(); + + const validateRequestWithPPOMOptionsBase = { + request: REQUEST_MOCK, + securityAlertId: SECURITY_ALERT_ID_MOCK, + chainId: CHAIN_ID_MOCK, + updateSecurityAlertResponse: updateSecurityAlertResponseMock, + }; + beforeEach(() => { jest.resetAllMocks(); jest.spyOn(console, 'error').mockImplementation(() => undefined); @@ -119,7 +132,7 @@ describe('PPOM Utils', () => { }); describe('validateRequestWithPPOM', () => { - it('returns response from validation with PPOM instance via controller', async () => { + it('updates response from validation with PPOM instance via controller', async () => { const ppom = createPPOMMock(); const ppomController = createPPOMControllerMock(); @@ -129,23 +142,39 @@ describe('PPOM Utils', () => { (callback) => callback(ppom as any) as any, ); - const response = await validateRequestWithPPOM({ + await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, - request: REQUEST_MOCK, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, - }); - - expect(response).toStrictEqual({ - ...SECURITY_ALERT_RESPONSE_MOCK, - securityAlertId: SECURITY_ALERT_ID_MOCK, }); + expect(updateSecurityAlertResponseMock).toHaveBeenCalledWith( + REQUEST_MOCK.method, + SECURITY_ALERT_ID_MOCK, + { + ...SECURITY_ALERT_RESPONSE_MOCK, + securityAlertId: SECURITY_ALERT_ID_MOCK, + }, + ); expect(ppom.validateJsonRpc).toHaveBeenCalledTimes(1); expect(ppom.validateJsonRpc).toHaveBeenCalledWith(REQUEST_MOCK); }); - it('returns error response if validation with PPOM instance throws', async () => { + it('updates securityAlertResponse with loading state', async () => { + const ppomController = createPPOMControllerMock(); + + await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, + ppomController, + }); + + expect(updateSecurityAlertResponseMock).toHaveBeenCalledWith( + REQUEST_MOCK.method, + SECURITY_ALERT_ID_MOCK, + LOADING_SECURITY_ALERT_RESPONSE, + ); + }); + + it('updates error response if validation with PPOM instance throws', async () => { const ppom = createPPOMMock(); const ppomController = createPPOMControllerMock(); @@ -157,37 +186,41 @@ describe('PPOM Utils', () => { callback(ppom as any) as any, ); - const response = await validateRequestWithPPOM({ + await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, - request: REQUEST_MOCK, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, }); - expect(response).toStrictEqual({ - result_type: BlockaidResultType.Errored, - reason: BlockaidReason.errored, - description: 'Test Error: Test error message', - }); + expect(updateSecurityAlertResponseMock).toHaveBeenCalledWith( + validateRequestWithPPOMOptionsBase.request.method, + SECURITY_ALERT_ID_MOCK, + { + result_type: BlockaidResultType.Errored, + reason: BlockaidReason.errored, + description: 'Test Error: Test error message', + }, + ); }); - it('returns error response if controller throws', async () => { + it('updates error response if controller throws', async () => { const ppomController = createPPOMControllerMock(); ppomController.usePPOM.mockRejectedValue(createErrorMock()); - const response = await validateRequestWithPPOM({ + await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, - request: REQUEST_MOCK, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, }); - expect(response).toStrictEqual({ - result_type: BlockaidResultType.Errored, - reason: BlockaidReason.errored, - description: 'Test Error: Test error message', - }); + expect(updateSecurityAlertResponseMock).toHaveBeenCalledWith( + validateRequestWithPPOMOptionsBase.request.method, + SECURITY_ALERT_ID_MOCK, + { + result_type: BlockaidResultType.Errored, + reason: BlockaidReason.errored, + description: 'Test Error: Test error message', + }, + ); }); it('normalizes request if method is eth_sendTransaction', async () => { @@ -209,10 +242,9 @@ describe('PPOM Utils', () => { }; await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, request, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, }); expect(ppom.validateJsonRpc).toHaveBeenCalledTimes(1); @@ -226,6 +258,23 @@ describe('PPOM Utils', () => { TRANSACTION_PARAMS_MOCK_1, ); }); + + it('updates response indicating chain is not supported', async () => { + const ppomController = {} as PPOMController; + const CHAIN_ID_UNSUPPORTED_MOCK = '0x2'; + + await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, + ppomController, + chainId: CHAIN_ID_UNSUPPORTED_MOCK, + }); + + expect(updateSecurityAlertResponseMock).toHaveBeenCalledWith( + validateRequestWithPPOMOptionsBase.request.method, + SECURITY_ALERT_ID_MOCK, + SECURITY_ALERT_RESPONSE_CHAIN_NOT_SUPPORTED, + ); + }); }); describe('generateSecurityAlertId', () => { @@ -318,10 +367,9 @@ describe('PPOM Utils', () => { const ppomController = createPPOMControllerMock(); await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, request, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, }); expect(ppomController.usePPOM).not.toHaveBeenCalled(); @@ -345,10 +393,9 @@ describe('PPOM Utils', () => { .mockRejectedValue(new Error('Test Error')); await validateRequestWithPPOM({ + ...validateRequestWithPPOMOptionsBase, ppomController, request, - securityAlertId: SECURITY_ALERT_ID_MOCK, - chainId: CHAIN_ID_MOCK, }); expect(ppomController.usePPOM).toHaveBeenCalledTimes(1); diff --git a/app/scripts/lib/ppom/ppom-util.ts b/app/scripts/lib/ppom/ppom-util.ts index e8c54ee5eb53..b0bf6b03b4d9 100644 --- a/app/scripts/lib/ppom/ppom-util.ts +++ b/app/scripts/lib/ppom/ppom-util.ts @@ -11,12 +11,14 @@ import { SignatureController } from '@metamask/signature-controller'; import { BlockaidReason, BlockaidResultType, + LOADING_SECURITY_ALERT_RESPONSE, + SECURITY_ALERT_RESPONSE_CHAIN_NOT_SUPPORTED, SECURITY_PROVIDER_SUPPORTED_CHAIN_IDS_FALLBACK_LIST, SecurityAlertSource, } from '../../../../shared/constants/security-provider'; import { SIGNING_METHODS } from '../../../../shared/constants/transaction'; import { AppStateController } from '../../controllers/app-state-controller'; -import { SecurityAlertResponse } from './types'; +import { SecurityAlertResponse, UpdateSecurityAlertResponse } from './types'; import { getSecurityAlertsAPISupportedChainIds, isSecurityAlertsAPIEnabled, @@ -37,18 +39,36 @@ type PPOMRequest = Omit & { method: typeof METHOD_SEND_TRANSACTION; params: [TransactionParams]; }; + export async function validateRequestWithPPOM({ ppomController, request, securityAlertId, chainId, + updateSecurityAlertResponse: updateSecurityResponse, }: { ppomController: PPOMController; request: JsonRpcRequest; securityAlertId: string; chainId: Hex; -}): Promise { + updateSecurityAlertResponse: UpdateSecurityAlertResponse; +}) { try { + if (!(await isChainSupported(chainId))) { + await updateSecurityResponse( + request.method, + securityAlertId, + SECURITY_ALERT_RESPONSE_CHAIN_NOT_SUPPORTED, + ); + return; + } + + await updateSecurityResponse( + request.method, + securityAlertId, + LOADING_SECURITY_ALERT_RESPONSE, + ); + const normalizedRequest = normalizePPOMRequest(request); const ppomResponse = isSecurityAlertsAPIEnabled() @@ -58,13 +78,13 @@ export async function validateRequestWithPPOM({ normalizedRequest, chainId, ); - - return { - ...ppomResponse, - securityAlertId, - }; + await updateSecurityResponse(request.method, securityAlertId, ppomResponse); } catch (error: unknown) { - return handlePPOMError(error, 'Error validating JSON RPC using PPOM: '); + await updateSecurityResponse( + request.method, + securityAlertId, + handlePPOMError(error, 'Error validating JSON RPC using PPOM: '), + ); } } @@ -97,12 +117,15 @@ export async function updateSecurityAlertResponse({ ); if (isSignatureRequest) { - appStateController.addSignatureSecurityAlertResponse(securityAlertResponse); + appStateController.addSignatureSecurityAlertResponse({ + ...securityAlertResponse, + securityAlertId, + }); } else { - transactionController.updateSecurityAlertResponse( - confirmation.id, - securityAlertResponse, - ); + transactionController.updateSecurityAlertResponse(confirmation.id, { + ...securityAlertResponse, + securityAlertId, + } as SecurityAlertResponse); } } diff --git a/app/scripts/lib/ppom/security-alerts-api.ts b/app/scripts/lib/ppom/security-alerts-api.ts index 258526f1b7c4..d0b6bc812b1a 100644 --- a/app/scripts/lib/ppom/security-alerts-api.ts +++ b/app/scripts/lib/ppom/security-alerts-api.ts @@ -51,7 +51,7 @@ async function request(endpoint: string, options?: RequestInit) { ); } - return response.json(); + return await response.json(); } function getUrl(endpoint: string) { diff --git a/app/scripts/lib/ppom/types.ts b/app/scripts/lib/ppom/types.ts index 6188d644aa12..57dd4a9e533d 100644 --- a/app/scripts/lib/ppom/types.ts +++ b/app/scripts/lib/ppom/types.ts @@ -10,3 +10,9 @@ export type SecurityAlertResponse = { securityAlertId?: string; source?: SecurityAlertSource; }; + +export type UpdateSecurityAlertResponse = ( + method: string, + securityAlertId: string, + securityAlertResponse: SecurityAlertResponse, +) => Promise; diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 354bb0bbb620..e268d25b2810 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -134,7 +134,7 @@ function getTracesSampleRate(sentryTarget) { return 1.0; } - return 0.02; + return 0.01; } /** diff --git a/app/scripts/lib/transaction/metrics.ts b/app/scripts/lib/transaction/metrics.ts index 1e0e26dc1c20..375a8bd4a8ab 100644 --- a/app/scripts/lib/transaction/metrics.ts +++ b/app/scripts/lib/transaction/metrics.ts @@ -43,16 +43,12 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../../ui/helpers/utils/metrics'; -import { - REDESIGN_DEV_TRANSACTION_TYPES, - REDESIGN_USER_TRANSACTION_TYPES, - // TODO: Remove restricted import - // eslint-disable-next-line import/no-restricted-paths -} from '../../../../ui/pages/confirmations/utils'; + import { getSnapAndHardwareInfoForMetrics, type SnapAndHardwareMessenger, } from '../snap-keyring/metrics'; +import { shouldUseRedesignForTransactions } from '../../../../shared/lib/confirmation.utils'; export type TransactionMetricsRequest = { createEventFragment: ( @@ -996,23 +992,15 @@ async function buildEventFragmentProperties({ if (simulationFails) { uiCustomizations.push(MetaMetricsEventUiCustomization.GasEstimationFailed); } - const isRedesignedConfirmationsDeveloperSettingEnabled = - transactionMetricsRequest.getIsRedesignedConfirmationsDeveloperEnabled() || - Boolean(process.env.ENABLE_CONFIRMATION_REDESIGN); - const isRedesignedTransactionsUserSettingEnabled = - transactionMetricsRequest.getRedesignedTransactionsEnabled(); - - if ( - (isRedesignedConfirmationsDeveloperSettingEnabled && - REDESIGN_DEV_TRANSACTION_TYPES.includes( - transactionMeta.type as TransactionType, - )) || - (isRedesignedTransactionsUserSettingEnabled && - REDESIGN_USER_TRANSACTION_TYPES.includes( - transactionMeta.type as TransactionType, - )) - ) { + const isRedesignedForTransaction = shouldUseRedesignForTransactions({ + transactionMetadataType: transactionMeta.type as TransactionType, + isRedesignedTransactionsUserSettingEnabled: + transactionMetricsRequest.getRedesignedTransactionsEnabled(), + isRedesignedConfirmationsDeveloperEnabled: + transactionMetricsRequest.getIsRedesignedConfirmationsDeveloperEnabled(), + }); + if (isRedesignedForTransaction) { uiCustomizations.push( MetaMetricsEventUiCustomization.RedesignedConfirmation, ); diff --git a/app/scripts/lib/transaction/util.test.ts b/app/scripts/lib/transaction/util.test.ts index 16077e0f08ed..fbbee025381b 100644 --- a/app/scripts/lib/transaction/util.test.ts +++ b/app/scripts/lib/transaction/util.test.ts @@ -16,7 +16,6 @@ import { BlockaidReason, BlockaidResultType, } from '../../../../shared/constants/security-provider'; -import { SecurityAlertResponse } from '../ppom/types'; import { flushPromises } from '../../../../test/lib/timer-helpers'; import { createMockInternalAccount } from '../../../../test/jest/mocks'; import { @@ -79,11 +78,6 @@ const TRANSACTION_REQUEST_MOCK: AddTransactionRequest = { internalAccounts: [], } as unknown as AddTransactionRequest; -const SECURITY_ALERT_RESPONSE_MOCK: SecurityAlertResponse = { - result_type: BlockaidResultType.Malicious, - reason: BlockaidReason.maliciousDomain, -}; - function createTransactionControllerMock() { return { addTransaction: jest.fn(), @@ -406,10 +400,6 @@ describe('Transaction Utils', () => { describe('validates using security provider', () => { it('adds loading response to request options', async () => { - validateRequestWithPPOMMock.mockResolvedValue( - SECURITY_ALERT_RESPONSE_MOCK, - ); - await addTransaction({ ...request, securityAlertsEnabled: true, @@ -425,36 +415,13 @@ describe('Transaction Utils', () => { ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { ...TRANSACTION_OPTIONS_MOCK, securityAlertResponse: { - reason: BlockaidReason.inProgress, + reason: BlockaidReason.checkingChain, result_type: BlockaidResultType.Loading, securityAlertId: SECURITY_ALERT_ID_MOCK, }, }); }); - it('updates response after validation', async () => { - validateRequestWithPPOMMock.mockResolvedValue( - SECURITY_ALERT_RESPONSE_MOCK, - ); - - await addTransaction({ - ...request, - securityAlertsEnabled: true, - chainId: '0x1', - }); - - await flushPromises(); - - expect(request.updateSecurityAlertResponse).toHaveBeenCalledTimes(1); - expect(request.updateSecurityAlertResponse).toHaveBeenCalledWith( - 'eth_sendTransaction', - SECURITY_ALERT_ID_MOCK, - SECURITY_ALERT_RESPONSE_MOCK, - ); - - expect(validateRequestWithPPOMMock).toHaveBeenCalledTimes(1); - }); - it('unless blockaid is disabled', async () => { await addTransaction({ ...request, @@ -505,29 +472,6 @@ describe('Transaction Utils', () => { expect(validateRequestWithPPOMMock).toHaveBeenCalledTimes(0); }); - it('unless chain is not supported', async () => { - isChainSupportedMock.mockResolvedValue(false); - - await addTransaction({ - ...request, - securityAlertsEnabled: true, - chainId: '0xF', - }); - - expect( - request.transactionController.addTransaction, - ).toHaveBeenCalledTimes(1); - - expect( - request.transactionController.addTransaction, - ).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK, - TRANSACTION_OPTIONS_MOCK, - ); - - expect(validateRequestWithPPOMMock).toHaveBeenCalledTimes(0); - }); - it('unless transaction type is swap', async () => { const swapRequest = { ...request }; swapRequest.transactionOptions.type = TransactionType.swap; diff --git a/app/scripts/lib/transaction/util.ts b/app/scripts/lib/transaction/util.ts index 8b71e33119f8..0bbf93afd8a8 100644 --- a/app/scripts/lib/transaction/util.ts +++ b/app/scripts/lib/transaction/util.ts @@ -16,12 +16,14 @@ import { PPOMController } from '@metamask/ppom-validator'; import { generateSecurityAlertId, handlePPOMError, - isChainSupported, validateRequestWithPPOM, } from '../ppom/ppom-util'; -import { SecurityAlertResponse } from '../ppom/types'; import { - LOADING_SECURITY_ALERT_RESPONSE, + SecurityAlertResponse, + UpdateSecurityAlertResponse, +} from '../ppom/types'; +import { + SECURITY_ALERT_RESPONSE_CHECKING_CHAIN, SECURITY_PROVIDER_EXCLUDED_TRANSACTION_TYPES, } from '../../../../shared/constants/security-provider'; import { endTrace, TraceName } from '../../../../shared/lib/trace'; @@ -38,11 +40,7 @@ type BaseAddTransactionRequest = { selectedAccount: InternalAccount; transactionParams: TransactionParams; transactionController: TransactionController; - updateSecurityAlertResponse: ( - method: string, - securityAlertId: string, - securityAlertResponse: SecurityAlertResponse, - ) => void; + updateSecurityAlertResponse: UpdateSecurityAlertResponse; userOperationController: UserOperationController; internalAccounts: InternalAccount[]; }; @@ -239,18 +237,12 @@ async function validateSecurity(request: AddTransactionRequest) { const { type } = transactionOptions; - const isCurrentChainSupported = await isChainSupported(chainId); - const typeIsExcludedFromPPOM = SECURITY_PROVIDER_EXCLUDED_TRANSACTION_TYPES.includes( type as TransactionType, ); - if ( - !securityAlertsEnabled || - !isCurrentChainSupported || - typeIsExcludedFromPPOM - ) { + if (!securityAlertsEnabled || typeIsExcludedFromPPOM) { return; } @@ -290,21 +282,16 @@ async function validateSecurity(request: AddTransactionRequest) { request: ppomRequest, securityAlertId, chainId, - }).then((securityAlertResponse) => { - updateSecurityAlertResponse( - ppomRequest.method, - securityAlertId, - securityAlertResponse, - ); + updateSecurityAlertResponse, }); - const loadingSecurityAlertResponse: SecurityAlertResponse = { - ...LOADING_SECURITY_ALERT_RESPONSE, + const securityAlertResponseCheckingChain: SecurityAlertResponse = { + ...SECURITY_ALERT_RESPONSE_CHECKING_CHAIN, securityAlertId, }; request.transactionOptions.securityAlertResponse = - loadingSecurityAlertResponse; + securityAlertResponseCheckingChain; } catch (error) { handlePPOMError(error, 'Error validating JSON RPC using PPOM: '); } diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index d43c12ff24a1..676595dfbff3 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -12,6 +12,7 @@ import { CodefiTokenPricesServiceV2, RatesController, fetchMultiExchangeRate, + TokenBalancesController, } from '@metamask/assets-controllers'; import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { createEngineStream } from '@metamask/json-rpc-middleware-stream'; @@ -308,7 +309,7 @@ import OnboardingController from './controllers/onboarding'; import Backup from './lib/backup'; import DecryptMessageController from './controllers/decrypt-message'; import SwapsController from './controllers/swaps'; -import MetaMetricsController from './controllers/metametrics'; +import MetaMetricsController from './controllers/metametrics-controller'; import { segment } from './lib/segment'; import createMetaRPCHandler from './lib/createMetaRPCHandler'; import { @@ -685,6 +686,7 @@ export default class MetamaskController extends EventEmitter { 'AccountsController:selectedEvmAccountChange', 'PreferencesController:stateChange', 'TokenListController:stateChange', + 'NetworkController:stateChange', ], }); this.tokensController = new TokensController({ @@ -764,23 +766,23 @@ export default class MetamaskController extends EventEmitter { disabled: !this.preferencesController.state.useNftDetection, }); + const metaMetricsControllerMessenger = + this.controllerMessenger.getRestricted({ + name: 'MetaMetricsController', + allowedActions: [ + 'PreferencesController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'NetworkController:networkDidChange', + ], + }); this.metaMetricsController = new MetaMetricsController({ - initState: initState.MetaMetricsController, + state: initState.MetaMetricsController, + messenger: metaMetricsControllerMessenger, segment, - preferencesControllerState: { - currentLocale: this.preferencesController.state.currentLocale, - selectedAddress: this.preferencesController.state.selectedAddress, - }, - onPreferencesStateChange: preferencesMessenger.subscribe.bind( - preferencesMessenger, - 'PreferencesController:stateChange', - ), - onNetworkDidChange: networkControllerMessenger.subscribe.bind( - networkControllerMessenger, - 'NetworkController:networkDidChange', - ), - getCurrentChainId: () => - getCurrentChainId({ metamask: this.networkController.state }), version: process.env.METAMASK_VERSION, environment: process.env.METAMASK_ENVIRONMENT, extension: this.extension, @@ -795,13 +797,14 @@ export default class MetamaskController extends EventEmitter { const metaMetricsDataDeletionMessenger = this.controllerMessenger.getRestricted({ name: 'MetaMetricsDataDeletionController', + allowedActions: ['MetaMetricsController:getState'], + allowedEvents: [], }); this.metaMetricsDataDeletionController = new MetaMetricsDataDeletionController({ dataDeletionService, messenger: metaMetricsDataDeletionMessenger, state: initState.metaMetricsDataDeletionController, - getMetaMetricsId: () => this.metaMetricsController.state.metaMetricsId, }); const gasFeeMessenger = this.controllerMessenger.getRestricted({ @@ -892,6 +895,28 @@ export default class MetamaskController extends EventEmitter { }; }; + const tokenBalancesMessenger = this.controllerMessenger.getRestricted({ + name: 'TokenBalancesController', + allowedActions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'TokensController:getState', + 'PreferencesController:getState', + 'AccountsController:getSelectedAccount', + ], + allowedEvents: [ + 'PreferencesController:stateChange', + 'TokensController:stateChange', + 'NetworkController:stateChange', + ], + }); + + this.tokenBalancesController = new TokenBalancesController({ + messenger: tokenBalancesMessenger, + state: initState.TokenBalancesController, + interval: 30000, + }); + const phishingControllerMessenger = this.controllerMessenger.getRestricted({ name: 'PhishingController', }); @@ -1501,6 +1526,9 @@ export default class MetamaskController extends EventEmitter { `${this.approvalController.name}:acceptRequest`, `${this.snapController.name}:get`, ], + allowedEvents: [ + 'NotificationServicesController:notificationsListUpdated', + ], }); this.snapInterfaceController = new SnapInterfaceController({ @@ -1570,6 +1598,16 @@ export default class MetamaskController extends EventEmitter { }, }); }, + onAccountSyncErroneousSituation: (profileId, situationMessage) => { + this.metaMetricsController.trackEvent({ + category: MetaMetricsEventCategory.ProfileSyncing, + event: MetaMetricsEventName.AccountsSyncErroneousSituation, + properties: { + profile_id: profileId, + situation_message: situationMessage, + }, + }); + }, }, }, env: { @@ -1740,10 +1778,11 @@ export default class MetamaskController extends EventEmitter { if (!prevCompletedOnboarding && currCompletedOnboarding) { const { address } = this.accountsController.getSelectedAccount(); - this._addAccountsWithBalance(); + await this._addAccountsWithBalance(); this.postOnboardingInitialization(); this.triggerNetworkrequests(); + // execute once the token detection on the post-onboarding await this.tokenDetectionController.detectTokens({ selectedAddress: address, @@ -2000,6 +2039,11 @@ export default class MetamaskController extends EventEmitter { ], }), trace, + decodingApiUrl: process.env.DECODING_API_URL, + isDecodeSignatureRequestEnabled: () => + this.preferencesController.state.useExternalServices === true && + this.preferencesController.state.useTransactionSimulations && + process.env.ENABLE_SIGNATURE_DECODING === true, }); this.signatureController.hub.on( @@ -2027,6 +2071,7 @@ export default class MetamaskController extends EventEmitter { 'AccountsController:listAccounts', 'AccountsController:getSelectedAccount', 'AccountsController:setSelectedAccount', + 'MetaMetricsController:getState', 'NetworkController:getState', 'NetworkController:setActiveNetwork', ], @@ -2042,6 +2087,7 @@ export default class MetamaskController extends EventEmitter { getState: this.getState.bind(this), getPendingNonce: this.getPendingNonce.bind(this), accountTrackerController: this.accountTrackerController, + networkController: this.networkController, metaMetricsController: this.metaMetricsController, permissionController: this.permissionController, signatureController: this.signatureController, @@ -2126,7 +2172,11 @@ export default class MetamaskController extends EventEmitter { const bridgeControllerMessenger = this.controllerMessenger.getRestricted({ name: BRIDGE_CONTROLLER_NAME, - allowedActions: ['AccountsController:getSelectedAccount'], + allowedActions: [ + 'AccountsController:getSelectedAccount', + 'NetworkController:getSelectedNetworkClient', + 'NetworkController:findNetworkClientIdByChainId', + ], allowedEvents: [], }); this.bridgeController = new BridgeController({ @@ -2385,7 +2435,7 @@ export default class MetamaskController extends EventEmitter { TransactionController: this.txController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, - MetaMetricsController: this.metaMetricsController.store, + MetaMetricsController: this.metaMetricsController, MetaMetricsDataDeletionController: this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, CurrencyController: this.currencyRateController, @@ -2401,6 +2451,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + TokenBalancesController: this.tokenBalancesController, SmartTransactionsController: this.smartTransactionsController, NftController: this.nftController, PhishingController: this.phishingController, @@ -2440,7 +2491,7 @@ export default class MetamaskController extends EventEmitter { NetworkController: this.networkController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, - MetaMetricsController: this.metaMetricsController.store, + MetaMetricsController: this.metaMetricsController, MetaMetricsDataDeletionController: this.metaMetricsDataDeletionController, AddressBookController: this.addressBookController, @@ -2456,6 +2507,7 @@ export default class MetamaskController extends EventEmitter { GasFeeController: this.gasFeeController, TokenListController: this.tokenListController, TokensController: this.tokensController, + TokenBalancesController: this.tokenBalancesController, SmartTransactionsController: this.smartTransactionsController, NftController: this.nftController, SelectedNetworkController: this.selectedNetworkController, @@ -2591,27 +2643,13 @@ export default class MetamaskController extends EventEmitter { } triggerNetworkrequests() { - this.accountTrackerController.start(); this.txController.startIncomingTransactionPolling(); this.tokenDetectionController.enable(); - - const preferencesControllerState = this.preferencesController.state; - - if (this.#isTokenListPollingRequired(preferencesControllerState)) { - this.tokenListController.start(); - } } stopNetworkRequests() { - this.accountTrackerController.stop(); this.txController.stopIncomingTransactionPolling(); this.tokenDetectionController.disable(); - - const preferencesControllerState = this.preferencesController.state; - - if (this.#isTokenListPollingRequired(preferencesControllerState)) { - this.tokenListController.stop(); - } } resetStates(resetMethods) { @@ -3171,7 +3209,7 @@ export default class MetamaskController extends EventEmitter { const { completedOnboarding } = this.onboardingController.state; let networkVersion = this.deprecatedNetworkVersions[networkClientId]; - if (!networkVersion && completedOnboarding) { + if (networkVersion === undefined && completedOnboarding) { const ethQuery = new EthQuery(networkClient.provider); networkVersion = await new Promise((resolve) => { ethQuery.sendAsync({ method: 'net_version' }, (error, result) => { @@ -3229,8 +3267,10 @@ export default class MetamaskController extends EventEmitter { nftController, nftDetectionController, currencyRateController, + tokenBalancesController, tokenDetectionController, ensController, + tokenListController, gasFeeController, metaMetricsController, networkController, @@ -3246,6 +3286,7 @@ export default class MetamaskController extends EventEmitter { approvalController, phishingController, tokenRatesController, + accountTrackerController, // Notification Controllers authenticationController, userStorageController, @@ -3940,6 +3981,15 @@ export default class MetamaskController extends EventEmitter { this.controllerMessenger, `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.SET_FEATURE_FLAGS}`, ), + [BridgeBackgroundAction.RESET_STATE]: this.controllerMessenger.call.bind( + this.controllerMessenger, + `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.RESET_STATE}`, + ), + [BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE]: + this.controllerMessenger.call.bind( + this.controllerMessenger, + `${BRIDGE_CONTROLLER_NAME}:${BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE}`, + ), [BridgeUserAction.SELECT_SRC_NETWORK]: this.controllerMessenger.call.bind( this.controllerMessenger, `${BRIDGE_CONTROLLER_NAME}:${BridgeUserAction.SELECT_SRC_NETWORK}`, @@ -4028,6 +4078,35 @@ export default class MetamaskController extends EventEmitter { tokenRatesController.stopPollingByPollingToken.bind( tokenRatesController, ), + accountTrackerStartPolling: + accountTrackerController.startPollingByNetworkClientId.bind( + accountTrackerController, + ), + accountTrackerStopPollingByPollingToken: + accountTrackerController.stopPollingByPollingToken.bind( + accountTrackerController, + ), + + tokenDetectionStartPolling: tokenDetectionController.startPolling.bind( + tokenDetectionController, + ), + tokenDetectionStopPollingByPollingToken: + tokenDetectionController.stopPollingByPollingToken.bind( + tokenDetectionController, + ), + + tokenListStartPolling: + tokenListController.startPolling.bind(tokenListController), + tokenListStopPollingByPollingToken: + tokenListController.stopPollingByPollingToken.bind(tokenListController), + + tokenBalancesStartPolling: tokenBalancesController.startPolling.bind( + tokenBalancesController, + ), + tokenBalancesStopPollingByPollingToken: + tokenBalancesController.stopPollingByPollingToken.bind( + tokenBalancesController, + ), // GasFeeController gasFeeStartPollingByNetworkClientId: @@ -4378,42 +4457,51 @@ export default class MetamaskController extends EventEmitter { } async _addAccountsWithBalance() { - // Scan accounts until we find an empty one - const chainId = getCurrentChainId({ - metamask: this.networkController.state, - }); - const ethQuery = new EthQuery(this.provider); - const accounts = await this.keyringController.getAccounts(); - let address = accounts[accounts.length - 1]; + try { + // Scan accounts until we find an empty one + const chainId = getCurrentChainId({ + metamask: this.networkController.state, + }); + const ethQuery = new EthQuery(this.provider); + const accounts = await this.keyringController.getAccounts(); + let address = accounts[accounts.length - 1]; - for (let count = accounts.length; ; count++) { - const balance = await this.getBalance(address, ethQuery); + for (let count = accounts.length; ; count++) { + const balance = await this.getBalance(address, ethQuery); - if (balance === '0x0') { - // This account has no balance, so check for tokens - await this.tokenDetectionController.detectTokens({ - selectedAddress: address, - }); + if (balance === '0x0') { + // This account has no balance, so check for tokens + await this.tokenDetectionController.detectTokens({ + chainIds: [chainId], + selectedAddress: address, + }); + + const tokens = + this.tokensController.state.allTokens?.[chainId]?.[address]; + const detectedTokens = + this.tokensController.state.allDetectedTokens?.[chainId]?.[address]; - const tokens = - this.tokensController.state.allTokens?.[chainId]?.[address]; - const detectedTokens = - this.tokensController.state.allDetectedTokens?.[chainId]?.[address]; - - if ( - (tokens?.length ?? 0) === 0 && - (detectedTokens?.length ?? 0) === 0 - ) { - // This account has no balance or tokens - if (count !== 1) { - await this.removeAccount(address); + if ( + (tokens?.length ?? 0) === 0 && + (detectedTokens?.length ?? 0) === 0 + ) { + // This account has no balance or tokens + if (count !== 1) { + await this.removeAccount(address); + } + break; } - break; } - } - // This account has assets, so check the next one - address = await this.keyringController.addNewAccount(count); + // This account has assets, so check the next one + address = await this.keyringController.addNewAccount(count); + } + } catch (e) { + log.warn(`Failed to add accounts with balance. Error: ${e}`); + } finally { + await this.userStorageController.setIsAccountSyncingReadyToBeDispatched( + true, + ); } } @@ -5145,7 +5233,7 @@ export default class MetamaskController extends EventEmitter { securityAlertId, securityAlertResponse, ) { - updateSecurityAlertResponse({ + await updateSecurityAlertResponse({ appStateController: this.appStateController, method, securityAlertId, @@ -5346,7 +5434,7 @@ export default class MetamaskController extends EventEmitter { metaMetricsId, dataCollectionForMarketing, participateInMetaMetrics, - } = this.metaMetricsController.store.getState(); + } = this.metaMetricsController.state; if ( metaMetricsId && @@ -5706,19 +5794,14 @@ export default class MetamaskController extends EventEmitter { ), ); - const isConfirmationRedesignEnabled = () => { - return this.preferencesController.state.preferences - .redesignedConfirmationsEnabled; - }; - engine.push( createRPCMethodTrackingMiddleware({ - getMetricsState: this.metaMetricsController.store.getState.bind( - this.metaMetricsController.store, - ), getAccountType: this.getAccountType.bind(this), getDeviceModel: this.getDeviceModel.bind(this), - isConfirmationRedesignEnabled, + isConfirmationRedesignEnabled: + this.isConfirmationRedesignEnabled.bind(this), + isRedesignedConfirmationsDeveloperEnabled: + this.isConfirmationRedesignDeveloperEnabled.bind(this), snapAndHardwareMessenger: this.controllerMessenger.getRestricted({ name: 'SnapAndHardwareMessenger', allowedActions: [ @@ -6356,6 +6439,21 @@ export default class MetamaskController extends EventEmitter { }); } + isConfirmationRedesignEnabled() { + return this.preferencesController.state.preferences + .redesignedConfirmationsEnabled; + } + + isTransactionsRedesignEnabled() { + return this.preferencesController.state.preferences + .redesignedTransactionsEnabled; + } + + isConfirmationRedesignDeveloperEnabled() { + return this.preferencesController.state.preferences + .isRedesignedConfirmationsDeveloperEnabled; + } + /** * The chain list is fetched live at runtime, falling back to a cache. * This preseeds the cache at startup with a static list provided at build. @@ -6540,12 +6638,10 @@ export default class MetamaskController extends EventEmitter { txHash, ); }, - getRedesignedConfirmationsEnabled: () => { - return this.preferencesController.getRedesignedConfirmationsEnabled; - }, - getRedesignedTransactionsEnabled: () => { - return this.preferencesController.getRedesignedTransactionsEnabled; - }, + getRedesignedConfirmationsEnabled: + this.isConfirmationRedesignEnabled.bind(this), + getRedesignedTransactionsEnabled: + this.isTransactionsRedesignEnabled.bind(this), getMethodData: (data) => { if (!data) { return null; @@ -6563,10 +6659,8 @@ export default class MetamaskController extends EventEmitter { this.provider, ); }, - getIsRedesignedConfirmationsDeveloperEnabled: () => { - return this.preferencesController.state.preferences - .isRedesignedConfirmationsDeveloperEnabled; - }, + getIsRedesignedConfirmationsDeveloperEnabled: + this.isConfirmationRedesignDeveloperEnabled.bind(this), getIsConfirmationAdvancedDetailsOpen: () => { return this.preferencesController.state.preferences .showConfirmationAdvancedDetails; @@ -6661,7 +6755,11 @@ export default class MetamaskController extends EventEmitter { this.gasFeeController.stopAllPolling(); this.currencyRateController.stopAllPolling(); this.tokenRatesController.stopAllPolling(); + this.tokenDetectionController.stopAllPolling(); + this.tokenListController.stopAllPolling(); + this.tokenBalancesController.stopAllPolling(); this.appStateController.clearPollingTokens(); + this.accountTrackerController.stopAllPolling(); } catch (error) { console.error(error); } @@ -6901,6 +6999,9 @@ export default class MetamaskController extends EventEmitter { await this._createTransactionNotifcation(transactionMeta); await this._updateNFTOwnership(transactionMeta); this._trackTransactionFailure(transactionMeta); + await this.tokenBalancesController.updateBalancesByChainId({ + chainId: transactionMeta.chainId, + }); } async _createTransactionNotifcation(transactionMeta) { @@ -7196,15 +7297,6 @@ export default class MetamaskController extends EventEmitter { } this.tokenListController.updatePreventPollingOnNetworkRestart(!newEnabled); - - if (newEnabled) { - log.debug('Started token list controller polling'); - this.tokenListController.start(); - } else { - log.debug('Stopped token list controller polling'); - this.tokenListController.clearingTokenListData(); - this.tokenListController.stop(); - } } #isTokenListPollingRequired(preferencesControllerState) { diff --git a/app/scripts/metamask-controller.test.js b/app/scripts/metamask-controller.test.js index 750f8771568b..2ccf864b0edd 100644 --- a/app/scripts/metamask-controller.test.js +++ b/app/scripts/metamask-controller.test.js @@ -2301,115 +2301,6 @@ describe('MetaMaskController', () => { }); }); - describe('token list controller', () => { - it('stops polling if petnames, simulations, and token detection disabled', async () => { - expect(TokenListController.prototype.stop).not.toHaveBeenCalled(); - - expect( - TokenListController.prototype.clearingTokenListData, - ).not.toHaveBeenCalled(); - - await simulatePreferencesChange({ - useTransactionSimulations: false, - useTokenDetection: false, - preferences: { - petnamesEnabled: false, - }, - }); - - expect(TokenListController.prototype.stop).toHaveBeenCalledTimes(1); - - expect( - TokenListController.prototype.clearingTokenListData, - ).toHaveBeenCalledTimes(1); - }); - - it.each([ - [ - 'petnames', - { - preferences: { petnamesEnabled: false }, - useTokenDetection: true, - useTransactionSimulations: true, - }, - ], - [ - 'simulations', - { - preferences: { petnamesEnabled: true }, - useTokenDetection: true, - useTransactionSimulations: false, - }, - ], - [ - 'token detection', - { - preferences: { petnamesEnabled: true }, - useTokenDetection: false, - useTransactionSimulations: true, - }, - ], - ])( - 'does not stop polling if only %s disabled', - async (_, preferences) => { - expect(TokenListController.prototype.stop).not.toHaveBeenCalled(); - - expect( - TokenListController.prototype.clearingTokenListData, - ).not.toHaveBeenCalled(); - - await simulatePreferencesChange(preferences); - - expect(TokenListController.prototype.stop).not.toHaveBeenCalled(); - - expect( - TokenListController.prototype.clearingTokenListData, - ).not.toHaveBeenCalled(); - }, - ); - - it.each([ - [ - 'petnames', - { - preferences: { petnamesEnabled: true }, - useTokenDetection: false, - useTransactionSimulations: false, - }, - ], - [ - 'simulations', - { - preferences: { petnamesEnabled: false }, - useTokenDetection: false, - useTransactionSimulations: true, - }, - ], - [ - 'token detection', - { - preferences: { petnamesEnabled: false }, - useTokenDetection: true, - useTransactionSimulations: false, - }, - ], - ])('starts polling if only %s enabled', async (_, preferences) => { - expect(TokenListController.prototype.start).not.toHaveBeenCalled(); - - await simulatePreferencesChange({ - useTransactionSimulations: false, - useTokenDetection: false, - preferences: { - petnamesEnabled: false, - }, - }); - - await simulatePreferencesChange(preferences); - - expect(TokenListController.prototype.start).toHaveBeenCalledTimes(1); - }); - }); - describe('MultichainRatesController start/stop', () => { const mockEvmAccount = createMockInternalAccount(); const mockNonEvmAccount = { diff --git a/builds.yml b/builds.yml index 2442ad51475a..fe33507c1e4a 100644 --- a/builds.yml +++ b/builds.yml @@ -27,7 +27,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management # Main build uses the default browser manifest manifestOverrides: false @@ -48,7 +48,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - ACCOUNT_SNAPS_DIRECTORY_URL: https://snaps.metamask.io/account-management # Modifies how the version is displayed. # eg. instead of 10.25.0 -> 10.25.0-beta.2 @@ -70,7 +70,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: true - REQUIRE_SNAPS_ALLOWLIST: false - REJECT_INVALID_SNAPS_PLATFORM_VERSION: false - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - SUPPORT_LINK: https://support.metamask.io/ - SUPPORT_REQUEST_LINK: https://support.metamask.io/ - INFURA_ENV_KEY_REF: INFURA_FLASK_PROJECT_ID @@ -94,7 +94,7 @@ buildTypes: - ALLOW_LOCAL_SNAPS: false - REQUIRE_SNAPS_ALLOWLIST: true - REJECT_INVALID_SNAPS_PLATFORM_VERSION: true - - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.9.2/index.html + - IFRAME_EXECUTION_ENVIRONMENT_URL: https://execution.metamask.io/iframe/6.10.0/index.html - MMI_CONFIGURATION_SERVICE_URL: https://configuration.metamask-institutional.io/v2/configuration/default - SUPPORT_LINK: https://support.metamask-institutional.io - SUPPORT_REQUEST_LINK: https://support.metamask-institutional.io @@ -235,6 +235,10 @@ env: # Used to enable confirmation redesigned pages - ENABLE_CONFIRMATION_REDESIGN: '' + # Used to enable signature decoding + - ENABLE_SIGNATURE_DECODING: '' + # URL of the decoding API used to provide additional data from signature requests + - DECODING_API_URL: null # Determines if feature flagged Settings Page - Developer Options should be used - ENABLE_SETTINGS_PAGE_DEV_OPTIONS: false # Used for debugging changes to the phishing warning page. @@ -280,8 +284,8 @@ env: - BARAD_DUR: '' # Determines if feature flagged Chain permissions - CHAIN_PERMISSIONS: '' - # Determines if feature flagged Filter toggle - - FILTER_TOKENS_TOGGLE: '' + # Determines if Portfolio View UI should be shown + - PORTFOLIO_VIEW: '' # Enables use of test gas fee flow to debug gas fee estimation - TEST_GAS_FEE_FLOWS: false # Temporary mechanism to enable security alerts API prior to release diff --git a/development/attributions-check.sh b/development/attributions-check.sh index 56ae63e7e1d3..15424e81b889 100755 --- a/development/attributions-check.sh +++ b/development/attributions-check.sh @@ -1,4 +1,9 @@ #!/bin/bash + +set -e +set -u +set -o pipefail + yarn attributions:generate ATTRIBUTIONS_FILE="./attribution.txt" diff --git a/development/build/styles.js b/development/build/styles.js index a8ac03c96ea5..8dbf0d42d94c 100644 --- a/development/build/styles.js +++ b/development/build/styles.js @@ -5,6 +5,7 @@ const gulpStylelint = require('gulp-stylelint'); const watch = require('gulp-watch'); const sourcemaps = require('gulp-sourcemaps'); const rtlcss = require('postcss-rtlcss'); +const discardFonts = require('postcss-discard-font-face'); const postcss = require('gulp-postcss'); const pipeline = pify(require('readable-stream').pipeline); const sass = require('sass-embedded'); @@ -83,7 +84,7 @@ async function buildScssPipeline(src, dest, devMode) { '-mm-fa-path()': () => new sass.SassString('./fonts/fontawesome'), }, }).on('error', gulpSass.logError), - postcss([autoprefixer(), rtlcss()]), + postcss([autoprefixer(), rtlcss(), discardFonts(['woff2'])]), devMode && sourcemaps.write(), gulp.dest(dest), ].filter(Boolean), diff --git a/development/clear-webpack-cache.js b/development/clear-webpack-cache.js new file mode 100755 index 000000000000..c6435b59a0e0 --- /dev/null +++ b/development/clear-webpack-cache.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const WEBPACK_CACHE_DIRECTORY = path.resolve( + __dirname, + '..', + 'node_modules', + '.cache', + 'webpack', +); + +/** + * Clear the Webpack cache. + * + * This is typically run in the `postinstall` npm/Yarn lifecycle script. + */ +async function main() { + await fs.rm(WEBPACK_CACHE_DIRECTORY, { force: true, recursive: true }); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/development/generate-attributions.sh b/development/generate-attributions.sh index 6d4b335430e2..40de26bb0459 100755 --- a/development/generate-attributions.sh +++ b/development/generate-attributions.sh @@ -27,6 +27,13 @@ main() { # relative to the project root irrespective of where this script was run. cd "${PROJECT_DIRECTORY}" + # Remove allow-scripts plugin. + # Allow-scripts won't run correctly after a production-only install because the configuration + # includes exact paths to each dependency, and those paths can change in a production-only + # install because of the hoisting algorithm. + # We don't need postinstall scripts to run in order to generate attributions anyway. + yarn plugin remove @yarnpkg/plugin-allow-scripts + # Instruct Yarn to only install production dependencies yarn workspaces focus --production @@ -38,7 +45,9 @@ main() { # Check if the script is running in a CI environment (GitHub Actions sets the CI variable to true) if [ -z "${CI:-}" ]; then - # If not running in CI, restore development dependencies + # If not running in CI, restore the allow-scripts plugin and development dependencies. + cd "${PROJECT_DIRECTORY}" + git checkout -- .yarnrc.yml .yarn yarn fi } diff --git a/development/webpack/utils/plugins/ManifestPlugin/index.ts b/development/webpack/utils/plugins/ManifestPlugin/index.ts index c08cfd7ba6e6..bd18541cc1e3 100644 --- a/development/webpack/utils/plugins/ManifestPlugin/index.ts +++ b/development/webpack/utils/plugins/ManifestPlugin/index.ts @@ -93,11 +93,6 @@ export class ManifestPlugin { '.txt', '.wasm', '.vtt', // very slow to process? - // ttf is disabled as some were getting corrupted during compression. You - // can test this by uncommenting it, running with --zip, and then unzipping - // the resulting zip file. If it is still broken the unzip operation will - // show an error. - // '.ttf', '.wav', '.xml', ]); diff --git a/development/webpack/webpack.config.ts b/development/webpack/webpack.config.ts index d207b3ae741c..9ec97b5507e1 100644 --- a/development/webpack/webpack.config.ts +++ b/development/webpack/webpack.config.ts @@ -17,6 +17,7 @@ import CopyPlugin from 'copy-webpack-plugin'; import HtmlBundlerPlugin from 'html-bundler-webpack-plugin'; import rtlCss from 'postcss-rtlcss'; import autoprefixer from 'autoprefixer'; +import discardFonts from 'postcss-discard-font-face'; import type ReactRefreshPluginType from '@pmmmwh/react-refresh-webpack-plugin'; import { SelfInjectPlugin } from './utils/plugins/SelfInjectPlugin'; import { @@ -112,6 +113,14 @@ const plugins: WebpackPluginInstance[] = [ isTest: args.test, shouldIncludeSnow: args.snow, }, + preload: [ + { + attributes: { as: 'font', crossorigin: true }, + // preload our own fonts, as other fonts use fallback formats we don't + // want to preload + test: /fonts\/\.(?:woff2)$/u, + }, + ], }), new ManifestPlugin({ web_accessible_resources: webAccessibleResources, @@ -282,6 +291,7 @@ const config = { plugins: [ autoprefixer({ overrideBrowserslist: browsersListQuery }), rtlCss({ processEnv: false }), + discardFonts(['woff2']), // keep woff2 fonts ], }, }, @@ -323,7 +333,7 @@ const config = { }, // images, fonts, wasm, etc. { - test: /\.(?:png|jpe?g|ico|webp|svg|gif|ttf|eot|woff2?|wasm)$/u, + test: /\.(?:png|jpe?g|ico|webp|svg|gif|woff2|wasm)$/u, type: 'asset/resource', generator: { filename: 'assets/[name].[contenthash][ext]' }, }, diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index eaac48a8f5f0..f87946a09c0f 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -686,6 +686,7 @@ "packages": { "@ensdomains/content-hash>multicodec>uint8arrays>multiformats": true, "@ethereumjs/tx>@ethereumjs/util": true, + "@ethersproject/bignumber": true, "@ethersproject/contracts": true, "@ethersproject/providers": true, "@metamask/abi-utils": true, @@ -895,7 +896,7 @@ "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, - "console.log": true, + "console.error": true, "document.createElement": true, "document.head.appendChild": true, "fetch": true, @@ -905,6 +906,7 @@ "@ethereumjs/tx": true, "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": true, "@metamask/eth-sig-util": true, "@metamask/eth-trezor-keyring>hdkey": true, "browserify>buffer": true, @@ -916,6 +918,152 @@ "TextEncoder": true } }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": { + "globals": { + "console.warn": true + }, + "packages": { + "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": true, + "browserify>buffer": true, + "ethers>@ethersproject/rlp": true, + "semver": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": { + "globals": { + "console.warn": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": true, + "@metamask/ppom-validator>crypto-js": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": { + "globals": { + "console.warn": true + }, + "packages": { + "wait-on>rxjs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": { + "packages": { + "@ethersproject/abi": true, + "@ethersproject/bignumber": true, + "@ethersproject/bytes": true, + "@ethersproject/contracts": true, + "@ethersproject/hash": true, + "@ethersproject/hdnode": true, + "@ethersproject/providers": true, + "@ethersproject/providers>@ethersproject/web": true, + "@ethersproject/wallet": true, + "ethers>@ethersproject/abstract-signer": true, + "ethers>@ethersproject/address": true, + "ethers>@ethersproject/base64": true, + "ethers>@ethersproject/basex": true, + "ethers>@ethersproject/constants": true, + "ethers>@ethersproject/json-wallets": true, + "ethers>@ethersproject/keccak256": true, + "ethers>@ethersproject/logger": true, + "ethers>@ethersproject/properties": true, + "ethers>@ethersproject/random": true, + "ethers>@ethersproject/rlp": true, + "ethers>@ethersproject/sha2": true, + "ethers>@ethersproject/signing-key": true, + "ethers>@ethersproject/solidity": true, + "ethers>@ethersproject/strings": true, + "ethers>@ethersproject/transactions": true, + "ethers>@ethersproject/units": true, + "ethers>@ethersproject/wordlists": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": { + "globals": { + "__ledgerLogsListen": "write", + "console.error": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": { + "globals": { + "crypto": true, + "define": true + } + }, "@metamask/eth-query": { "packages": { "@metamask/eth-query>json-rpc-random-id": true, @@ -1771,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, @@ -2456,6 +2576,9 @@ } }, "@metamask/signature-controller": { + "globals": { + "fetch": true + }, "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, @@ -2521,9 +2644,9 @@ "@metamask/smart-transactions-controller>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": true, - "@metamask/smart-transactions-controller>@ethereumjs/util": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true + "@metamask/smart-transactions-controller>@ethereumjs/util": true } }, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": { @@ -2539,7 +2662,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -3650,6 +3773,11 @@ "process": true } }, + "axios>form-data": { + "globals": { + "FormData": true + } + }, "base32-encode": { "packages": { "base32-encode>to-data-view": true @@ -4165,6 +4293,7 @@ "packages": { "@ethereumjs/tx>@ethereumjs/common>crc-32": true, "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/eth-sig-util": true, "@metamask/ethjs>js-sha3": true, "@metamask/keyring-api>bech32": true, @@ -4172,7 +4301,6 @@ "bn.js": true, "browserify>buffer": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/common": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": true, "eth-lattice-keyring>gridplus-sdk>aes-js": true, "eth-lattice-keyring>gridplus-sdk>bignumber.js": true, @@ -4190,15 +4318,10 @@ "webpack>events": true } }, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": { - "globals": { - "TextEncoder": true - } - }, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/common": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/util": true } @@ -4216,7 +4339,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -5690,6 +5813,17 @@ "msCrypto": true } }, + "wait-on>rxjs": { + "globals": { + "cancelAnimationFrame": true, + "clearInterval": true, + "clearTimeout": true, + "performance": true, + "requestAnimationFrame": true, + "setInterval.apply": true, + "setTimeout.apply": true + } + }, "web3": { "globals": { "XMLHttpRequest": true diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index eaac48a8f5f0..f87946a09c0f 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -686,6 +686,7 @@ "packages": { "@ensdomains/content-hash>multicodec>uint8arrays>multiformats": true, "@ethereumjs/tx>@ethereumjs/util": true, + "@ethersproject/bignumber": true, "@ethersproject/contracts": true, "@ethersproject/providers": true, "@metamask/abi-utils": true, @@ -895,7 +896,7 @@ "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, - "console.log": true, + "console.error": true, "document.createElement": true, "document.head.appendChild": true, "fetch": true, @@ -905,6 +906,7 @@ "@ethereumjs/tx": true, "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": true, "@metamask/eth-sig-util": true, "@metamask/eth-trezor-keyring>hdkey": true, "browserify>buffer": true, @@ -916,6 +918,152 @@ "TextEncoder": true } }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": { + "globals": { + "console.warn": true + }, + "packages": { + "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": true, + "browserify>buffer": true, + "ethers>@ethersproject/rlp": true, + "semver": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": { + "globals": { + "console.warn": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": true, + "@metamask/ppom-validator>crypto-js": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": { + "globals": { + "console.warn": true + }, + "packages": { + "wait-on>rxjs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": { + "packages": { + "@ethersproject/abi": true, + "@ethersproject/bignumber": true, + "@ethersproject/bytes": true, + "@ethersproject/contracts": true, + "@ethersproject/hash": true, + "@ethersproject/hdnode": true, + "@ethersproject/providers": true, + "@ethersproject/providers>@ethersproject/web": true, + "@ethersproject/wallet": true, + "ethers>@ethersproject/abstract-signer": true, + "ethers>@ethersproject/address": true, + "ethers>@ethersproject/base64": true, + "ethers>@ethersproject/basex": true, + "ethers>@ethersproject/constants": true, + "ethers>@ethersproject/json-wallets": true, + "ethers>@ethersproject/keccak256": true, + "ethers>@ethersproject/logger": true, + "ethers>@ethersproject/properties": true, + "ethers>@ethersproject/random": true, + "ethers>@ethersproject/rlp": true, + "ethers>@ethersproject/sha2": true, + "ethers>@ethersproject/signing-key": true, + "ethers>@ethersproject/solidity": true, + "ethers>@ethersproject/strings": true, + "ethers>@ethersproject/transactions": true, + "ethers>@ethersproject/units": true, + "ethers>@ethersproject/wordlists": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": { + "globals": { + "__ledgerLogsListen": "write", + "console.error": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": { + "globals": { + "crypto": true, + "define": true + } + }, "@metamask/eth-query": { "packages": { "@metamask/eth-query>json-rpc-random-id": true, @@ -1771,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, @@ -2456,6 +2576,9 @@ } }, "@metamask/signature-controller": { + "globals": { + "fetch": true + }, "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, @@ -2521,9 +2644,9 @@ "@metamask/smart-transactions-controller>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": true, - "@metamask/smart-transactions-controller>@ethereumjs/util": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true + "@metamask/smart-transactions-controller>@ethereumjs/util": true } }, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": { @@ -2539,7 +2662,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -3650,6 +3773,11 @@ "process": true } }, + "axios>form-data": { + "globals": { + "FormData": true + } + }, "base32-encode": { "packages": { "base32-encode>to-data-view": true @@ -4165,6 +4293,7 @@ "packages": { "@ethereumjs/tx>@ethereumjs/common>crc-32": true, "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/eth-sig-util": true, "@metamask/ethjs>js-sha3": true, "@metamask/keyring-api>bech32": true, @@ -4172,7 +4301,6 @@ "bn.js": true, "browserify>buffer": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/common": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": true, "eth-lattice-keyring>gridplus-sdk>aes-js": true, "eth-lattice-keyring>gridplus-sdk>bignumber.js": true, @@ -4190,15 +4318,10 @@ "webpack>events": true } }, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": { - "globals": { - "TextEncoder": true - } - }, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/common": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/util": true } @@ -4216,7 +4339,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -5690,6 +5813,17 @@ "msCrypto": true } }, + "wait-on>rxjs": { + "globals": { + "cancelAnimationFrame": true, + "clearInterval": true, + "clearTimeout": true, + "performance": true, + "requestAnimationFrame": true, + "setInterval.apply": true, + "setTimeout.apply": true + } + }, "web3": { "globals": { "XMLHttpRequest": true diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index eaac48a8f5f0..f87946a09c0f 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -686,6 +686,7 @@ "packages": { "@ensdomains/content-hash>multicodec>uint8arrays>multiformats": true, "@ethereumjs/tx>@ethereumjs/util": true, + "@ethersproject/bignumber": true, "@ethersproject/contracts": true, "@ethersproject/providers": true, "@metamask/abi-utils": true, @@ -895,7 +896,7 @@ "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, - "console.log": true, + "console.error": true, "document.createElement": true, "document.head.appendChild": true, "fetch": true, @@ -905,6 +906,7 @@ "@ethereumjs/tx": true, "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": true, "@metamask/eth-sig-util": true, "@metamask/eth-trezor-keyring>hdkey": true, "browserify>buffer": true, @@ -916,6 +918,152 @@ "TextEncoder": true } }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": { + "globals": { + "console.warn": true + }, + "packages": { + "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": true, + "browserify>buffer": true, + "ethers>@ethersproject/rlp": true, + "semver": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": { + "globals": { + "console.warn": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": true, + "@metamask/ppom-validator>crypto-js": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": { + "globals": { + "console.warn": true + }, + "packages": { + "wait-on>rxjs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": { + "packages": { + "@ethersproject/abi": true, + "@ethersproject/bignumber": true, + "@ethersproject/bytes": true, + "@ethersproject/contracts": true, + "@ethersproject/hash": true, + "@ethersproject/hdnode": true, + "@ethersproject/providers": true, + "@ethersproject/providers>@ethersproject/web": true, + "@ethersproject/wallet": true, + "ethers>@ethersproject/abstract-signer": true, + "ethers>@ethersproject/address": true, + "ethers>@ethersproject/base64": true, + "ethers>@ethersproject/basex": true, + "ethers>@ethersproject/constants": true, + "ethers>@ethersproject/json-wallets": true, + "ethers>@ethersproject/keccak256": true, + "ethers>@ethersproject/logger": true, + "ethers>@ethersproject/properties": true, + "ethers>@ethersproject/random": true, + "ethers>@ethersproject/rlp": true, + "ethers>@ethersproject/sha2": true, + "ethers>@ethersproject/signing-key": true, + "ethers>@ethersproject/solidity": true, + "ethers>@ethersproject/strings": true, + "ethers>@ethersproject/transactions": true, + "ethers>@ethersproject/units": true, + "ethers>@ethersproject/wordlists": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": { + "globals": { + "__ledgerLogsListen": "write", + "console.error": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": { + "globals": { + "crypto": true, + "define": true + } + }, "@metamask/eth-query": { "packages": { "@metamask/eth-query>json-rpc-random-id": true, @@ -1771,40 +1919,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, @@ -2456,6 +2576,9 @@ } }, "@metamask/signature-controller": { + "globals": { + "fetch": true + }, "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, @@ -2521,9 +2644,9 @@ "@metamask/smart-transactions-controller>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": true, - "@metamask/smart-transactions-controller>@ethereumjs/util": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true + "@metamask/smart-transactions-controller>@ethereumjs/util": true } }, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": { @@ -2539,7 +2662,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -3650,6 +3773,11 @@ "process": true } }, + "axios>form-data": { + "globals": { + "FormData": true + } + }, "base32-encode": { "packages": { "base32-encode>to-data-view": true @@ -4165,6 +4293,7 @@ "packages": { "@ethereumjs/tx>@ethereumjs/common>crc-32": true, "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/eth-sig-util": true, "@metamask/ethjs>js-sha3": true, "@metamask/keyring-api>bech32": true, @@ -4172,7 +4301,6 @@ "bn.js": true, "browserify>buffer": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/common": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": true, "eth-lattice-keyring>gridplus-sdk>aes-js": true, "eth-lattice-keyring>gridplus-sdk>bignumber.js": true, @@ -4190,15 +4318,10 @@ "webpack>events": true } }, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": { - "globals": { - "TextEncoder": true - } - }, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/common": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/util": true } @@ -4216,7 +4339,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -5690,6 +5813,17 @@ "msCrypto": true } }, + "wait-on>rxjs": { + "globals": { + "cancelAnimationFrame": true, + "clearInterval": true, + "clearTimeout": true, + "performance": true, + "requestAnimationFrame": true, + "setInterval.apply": true, + "setTimeout.apply": true + } + }, "web3": { "globals": { "XMLHttpRequest": true diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 467ef781215c..1bad9f3288a2 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -778,6 +778,7 @@ "packages": { "@ensdomains/content-hash>multicodec>uint8arrays>multiformats": true, "@ethereumjs/tx>@ethereumjs/util": true, + "@ethersproject/bignumber": true, "@ethersproject/contracts": true, "@ethersproject/providers": true, "@metamask/abi-utils": true, @@ -987,7 +988,7 @@ "@metamask/eth-ledger-bridge-keyring": { "globals": { "addEventListener": true, - "console.log": true, + "console.error": true, "document.createElement": true, "document.head.appendChild": true, "fetch": true, @@ -997,6 +998,7 @@ "@ethereumjs/tx": true, "@ethereumjs/tx>@ethereumjs/util": true, "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": true, "@metamask/eth-sig-util": true, "@metamask/eth-trezor-keyring>hdkey": true, "browserify>buffer": true, @@ -1008,6 +1010,152 @@ "TextEncoder": true } }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth": { + "globals": { + "console.warn": true + }, + "packages": { + "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": true, + "browserify>buffer": true, + "ethers>@ethersproject/rlp": true, + "semver": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/domain-service>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/errors": { + "globals": { + "console.warn": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools": { + "packages": { + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/cryptoassets-evm-signatures": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": true, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": true, + "@metamask/ppom-validator>crypto-js": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>@ledgerhq/live-env": { + "globals": { + "console.warn": true + }, + "packages": { + "wait-on>rxjs": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/evm-tools>ethers": { + "packages": { + "@ethersproject/abi": true, + "@ethersproject/bignumber": true, + "@ethersproject/bytes": true, + "@ethersproject/contracts": true, + "@ethersproject/hash": true, + "@ethersproject/hdnode": true, + "@ethersproject/providers": true, + "@ethersproject/providers>@ethersproject/web": true, + "@ethersproject/wallet": true, + "ethers>@ethersproject/abstract-signer": true, + "ethers>@ethersproject/address": true, + "ethers>@ethersproject/base64": true, + "ethers>@ethersproject/basex": true, + "ethers>@ethersproject/constants": true, + "ethers>@ethersproject/json-wallets": true, + "ethers>@ethersproject/keccak256": true, + "ethers>@ethersproject/logger": true, + "ethers>@ethersproject/properties": true, + "ethers>@ethersproject/random": true, + "ethers>@ethersproject/rlp": true, + "ethers>@ethersproject/sha2": true, + "ethers>@ethersproject/signing-key": true, + "ethers>@ethersproject/solidity": true, + "ethers>@ethersproject/strings": true, + "ethers>@ethersproject/transactions": true, + "ethers>@ethersproject/units": true, + "ethers>@ethersproject/wordlists": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>@ledgerhq/logs": { + "globals": { + "__ledgerLogsListen": "write", + "console.error": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>axios": { + "globals": { + "Blob": true, + "FormData": true, + "URLSearchParams": true, + "XMLHttpRequest": true, + "btoa": true, + "console.warn": true, + "document": true, + "location.href": true, + "navigator": true, + "setTimeout": true + }, + "packages": { + "axios>form-data": true, + "browserify>buffer": true, + "process": true + } + }, + "@metamask/eth-ledger-bridge-keyring>@ledgerhq/hw-app-eth>bignumber.js": { + "globals": { + "crypto": true, + "define": true + } + }, "@metamask/eth-query": { "packages": { "@metamask/eth-query>json-rpc-random-id": true, @@ -1863,40 +2011,12 @@ }, "@metamask/network-controller>@metamask/eth-json-rpc-provider": { "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, + "@metamask/json-rpc-engine": true, + "@metamask/rpc-errors": true, "@metamask/safe-event-emitter": true, "uuid": true } }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/json-rpc-engine": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": true, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/safe-event-emitter": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/rpc-errors": { - "packages": { - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": true, - "@metamask/rpc-errors>fast-safe-stringify": true - } - }, - "@metamask/network-controller>@metamask/eth-json-rpc-provider>@metamask/utils": { - "globals": { - "TextDecoder": true, - "TextEncoder": true - }, - "packages": { - "@metamask/utils>@metamask/superstruct": true, - "@metamask/utils>@scure/base": true, - "@metamask/utils>pony-cause": true, - "@noble/hashes": true, - "browserify>buffer": true, - "nock>debug": true, - "semver": true - } - }, "@metamask/network-controller>@metamask/json-rpc-engine": { "packages": { "@metamask/network-controller>@metamask/rpc-errors": true, @@ -2548,6 +2668,9 @@ } }, "@metamask/signature-controller": { + "globals": { + "fetch": true + }, "packages": { "@metamask/base-controller": true, "@metamask/controller-utils": true, @@ -2613,9 +2736,9 @@ "@metamask/smart-transactions-controller>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": true, - "@metamask/smart-transactions-controller>@ethereumjs/util": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true + "@metamask/smart-transactions-controller>@ethereumjs/util": true } }, "@metamask/smart-transactions-controller>@ethereumjs/tx>@ethereumjs/common": { @@ -2631,7 +2754,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -3742,6 +3865,11 @@ "process": true } }, + "axios>form-data": { + "globals": { + "FormData": true + } + }, "base32-encode": { "packages": { "base32-encode>to-data-view": true @@ -4257,6 +4385,7 @@ "packages": { "@ethereumjs/tx>@ethereumjs/common>crc-32": true, "@ethersproject/abi": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "@metamask/eth-sig-util": true, "@metamask/ethjs>js-sha3": true, "@metamask/keyring-api>bech32": true, @@ -4264,7 +4393,6 @@ "bn.js": true, "browserify>buffer": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/common": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": true, "eth-lattice-keyring>gridplus-sdk>aes-js": true, "eth-lattice-keyring>gridplus-sdk>bignumber.js": true, @@ -4282,15 +4410,10 @@ "webpack>events": true } }, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": { - "globals": { - "TextEncoder": true - } - }, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx": { "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/common": true, "eth-lattice-keyring>gridplus-sdk>@ethereumjs/tx>@ethereumjs/util": true } @@ -4308,7 +4431,7 @@ }, "packages": { "@ethereumjs/tx>ethereum-cryptography": true, - "eth-lattice-keyring>gridplus-sdk>@ethereumjs/rlp": true, + "@metamask/eth-ledger-bridge-keyring>@ethereumjs/rlp": true, "webpack>events": true } }, @@ -5758,6 +5881,17 @@ "msCrypto": true } }, + "wait-on>rxjs": { + "globals": { + "cancelAnimationFrame": true, + "clearInterval": true, + "clearTimeout": true, + "performance": true, + "requestAnimationFrame": true, + "setInterval.apply": true, + "setTimeout.apply": true + } + }, "web3": { "globals": { "XMLHttpRequest": true diff --git a/lavamoat/build-system/policy.json b/lavamoat/build-system/policy.json index 2d6b7ce3ad14..5338922720ef 100644 --- a/lavamoat/build-system/policy.json +++ b/lavamoat/build-system/policy.json @@ -3174,22 +3174,8 @@ "__dirname": true }, "packages": { - "eslint>file-entry-cache>flat-cache>flatted": true, - "eslint>file-entry-cache>flat-cache>rimraf": true - } - }, - "eslint>file-entry-cache>flat-cache>rimraf": { - "builtin": { - "assert": true, - "fs": true, - "path.join": true - }, - "globals": { - "process.platform": true, - "setTimeout": true - }, - "packages": { - "nyc>glob": true + "del>rimraf": true, + "eslint>file-entry-cache>flat-cache>flatted": true } }, "eslint>glob-parent": { @@ -6579,6 +6565,72 @@ "postcss>source-map-js": true } }, + "postcss-discard-font-face": { + "packages": { + "postcss-discard-font-face>balanced-match": true, + "postcss-discard-font-face>postcss": true + } + }, + "postcss-discard-font-face>postcss": { + "builtin": { + "fs": true, + "path": true + }, + "globals": { + "console": true + }, + "packages": { + "postcss-discard-font-face>postcss>chalk": true, + "postcss-discard-font-face>postcss>js-base64": true, + "postcss-discard-font-face>postcss>source-map": true, + "postcss-discard-font-face>postcss>supports-color": true + } + }, + "postcss-discard-font-face>postcss>chalk": { + "globals": { + "process.env.TERM": true, + "process.platform": true + }, + "packages": { + "postcss-discard-font-face>postcss>chalk>ansi-styles": true, + "postcss-discard-font-face>postcss>chalk>escape-string-regexp": true, + "postcss-discard-font-face>postcss>chalk>strip-ansi": true, + "postcss-discard-font-face>postcss>chalk>supports-color": true, + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi": true + } + }, + "postcss-discard-font-face>postcss>chalk>strip-ansi": { + "packages": { + "postcss-discard-font-face>postcss>chalk>strip-ansi>ansi-regex": true + } + }, + "postcss-discard-font-face>postcss>chalk>supports-color": { + "globals": { + "process.argv": true, + "process.env": true, + "process.platform": true, + "process.stdout": true + } + }, + "postcss-discard-font-face>postcss>js-base64": { + "globals": { + "Base64": "write", + "define": true + } + }, + "postcss-discard-font-face>postcss>supports-color": { + "globals": { + "process": true + }, + "packages": { + "postcss-discard-font-face>postcss>supports-color>has-flag": true + } + }, + "postcss-discard-font-face>postcss>supports-color>has-flag": { + "globals": { + "process.argv": true + } + }, "postcss-rtlcss": { "globals": { "SuppressedError": true @@ -7397,6 +7449,11 @@ "zipToModeAwareCache": true } }, + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi": { + "packages": { + "prettier-eslint>loglevel-colored-level-prefix>chalk>has-ansi>ansi-regex": true + } + }, "prop-types": { "globals": { "console": true, diff --git a/package.json b/package.json index 13bddaa69287..fb6218ebb5c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "12.6.0", + "version": "12.7.0", "private": true, "repository": { "type": "git", @@ -8,7 +8,7 @@ }, "scripts": { "webpack": "tsx ./development/webpack/launch.ts", - "webpack:clearcache": "rimraf node_modules/.cache/webpack", + "webpack:clearcache": "./development/clear-webpack-cache.js", "postinstall": "yarn webpack:clearcache", "env:e2e": "SEGMENT_HOST='https://api.segment.io' SEGMENT_WRITE_KEY='FAKE' yarn", "start": "yarn build:dev dev --apply-lavamoat=false --snow=false", @@ -113,7 +113,7 @@ "ts-migration:dashboard:watch": "yarn ts-migration:dashboard:build --watch", "ts-migration:enumerate": "ts-node development/ts-migration-dashboard/scripts/write-list-of-files-to-convert.ts", "test-storybook": "test-storybook -c .storybook", - "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn storybook:build && npx http-server storybook-build --port 6006 \" \"wait-on tcp:6006 && echo 'Build done. Running storybook tests...' && npx playwright install && yarn test-storybook --maxWorkers=2\"", + "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn storybook:build && npx http-server storybook-build --port 6006 \" \"wait-on tcp:6006 && echo 'Build done. Running storybook tests...' && yarn test-storybook --maxWorkers=2\"", "githooks:install": "husky install", "fitness-functions": "ts-node development/fitness-functions/index.ts", "generate-beta-commit": "node ./development/generate-beta-commit.js", @@ -233,7 +233,7 @@ "semver@7.3.8": "^7.5.4", "@trezor/schema-utils@npm:1.0.2": "patch:@trezor/schema-utils@npm%3A1.0.2#~/.yarn/patches/@trezor-schema-utils-npm-1.0.2-7dd48689b2.patch", "lavamoat-core@npm:^15.1.1": "patch:lavamoat-core@npm%3A15.1.1#~/.yarn/patches/lavamoat-core-npm-15.1.1-51fbe39988.patch", - "@metamask/snaps-sdk": "^6.10.0", + "@metamask/snaps-sdk": "^6.11.0", "@swc/types@0.1.5": "^0.1.6", "@babel/core": "patch:@babel/core@npm%3A7.25.9#~/.yarn/patches/@babel-core-npm-7.25.9-4ae3bff7f3.patch", "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", @@ -250,7 +250,14 @@ "@metamask/network-controller@npm:^17.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/network-controller@npm:^19.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", "@metamask/network-controller@npm:^20.0.0": "patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch", - "path-to-regexp": "1.9.0" + "path-to-regexp": "1.9.0", + "@ledgerhq/cryptoassets-evm-signatures/axios": "^0.28.0", + "@ledgerhq/domain-service/axios": "^0.28.0", + "@ledgerhq/evm-tools/axios": "^0.28.0", + "@ledgerhq/hw-app-eth/axios": "^0.28.0", + "@ledgerhq/hw-app-eth@npm:^6.39.0": "patch:@ledgerhq/hw-app-eth@npm%3A6.39.0#~/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch", + "@ledgerhq/evm-tools@npm:^1.2.3": "patch:@ledgerhq/evm-tools@npm%3A1.2.3#~/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch", + "cross-spawn@npm:^5.0.1": "^7.0.5" }, "dependencies": { "@babel/runtime": "patch:@babel/runtime@npm%3A7.25.9#~/.yarn/patches/@babel-runtime-npm-7.25.9-fe8c62510a.patch", @@ -286,7 +293,7 @@ "@metamask/address-book-controller": "^6.0.0", "@metamask/announcement-controller": "^7.0.0", "@metamask/approval-controller": "^7.0.0", - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch", + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", @@ -297,7 +304,7 @@ "@metamask/ens-resolver-snap": "^0.1.2", "@metamask/eth-json-rpc-filters": "^9.0.0", "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch", - "@metamask/eth-ledger-bridge-keyring": "^3.0.1", + "@metamask/eth-ledger-bridge-keyring": "^5.0.1", "@metamask/eth-query": "^4.0.0", "@metamask/eth-sig-util": "^7.0.1", "@metamask/eth-snap-keyring": "^4.4.0", @@ -331,7 +338,7 @@ "@metamask/post-message-stream": "^8.0.0", "@metamask/ppom-validator": "0.35.1", "@metamask/preinstalled-example-snap": "^0.2.0", - "@metamask/profile-sync-controller": "^0.9.7", + "@metamask/profile-sync-controller": "^1.0.2", "@metamask/providers": "^14.0.2", "@metamask/queued-request-controller": "^7.0.1", "@metamask/rate-limit-controller": "^6.0.0", @@ -339,15 +346,15 @@ "@metamask/safe-event-emitter": "^3.1.1", "@metamask/scure-bip39": "^2.0.3", "@metamask/selected-network-controller": "^18.0.2", - "@metamask/signature-controller": "^21.0.0", + "@metamask/signature-controller": "^21.1.0", "@metamask/smart-transactions-controller": "^13.0.0", - "@metamask/snaps-controllers": "^9.12.0", - "@metamask/snaps-execution-environments": "^6.9.2", + "@metamask/snaps-controllers": "^9.13.0", + "@metamask/snaps-execution-environments": "^6.10.0", "@metamask/snaps-rpc-methods": "^11.5.1", - "@metamask/snaps-sdk": "^6.10.0", - "@metamask/snaps-utils": "^8.5.1", + "@metamask/snaps-sdk": "^6.11.0", + "@metamask/snaps-utils": "^8.6.0", "@metamask/solana-wallet-snap": "^0.1.9", - "@metamask/transaction-controller": "^38.3.0", + "@metamask/transaction-controller": "^39.1.0", "@metamask/user-operation-controller": "^13.0.0", "@metamask/utils": "^10.0.1", "@ngraveio/bc-ur": "^1.1.12", @@ -554,7 +561,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^12.0.2", "core-js-pure": "^3.38.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "crypto-browserify": "^3.12.0", "css-loader": "^6.10.0", "css-to-xpath": "^0.1.0", @@ -624,6 +631,7 @@ "nyc": "^15.1.0", "path-browserify": "^1.0.1", "postcss": "^8.4.32", + "postcss-discard-font-face": "^3.0.0", "postcss-loader": "^8.1.1", "postcss-rtlcss": "^4.0.9", "prettier": "^2.7.1", @@ -640,7 +648,6 @@ "redux-mock-store": "^1.5.4", "remote-redux-devtools": "^0.5.16", "resolve-url-loader": "^3.1.5", - "rimraf": "^5.0.5", "sass-embedded": "^1.71.0", "sass-loader": "^14.1.1", "schema-utils": "^4.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 6632e1983e79..0145cd3dcc49 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -71,6 +71,7 @@ const config: PlaywrightTestConfig = { ...devices['Desktop Chrome'], headless: true, }, + fullyParallel: false, }, // Global: universal, common, shared, and non feature related tests { diff --git a/privacy-snapshot.json b/privacy-snapshot.json index 817d1e102bff..f5cf068b8728 100644 --- a/privacy-snapshot.json +++ b/privacy-snapshot.json @@ -52,12 +52,13 @@ "security-alerts.api.cx.metamask.io", "security-alerts.dev-api.cx.metamask.io", "sentry.io", + "sepolia.infura.io", "snaps.metamask.io", "sourcify.dev", "start.metamask.io", "static.cx.metamask.io", - "support.metamask.io", "support.metamask-institutional.io", + "support.metamask.io", "swap.api.cx.metamask.io", "test.metamask-phishing.io", "token.api.cx.metamask.io", diff --git a/shared/constants/app-state.ts b/shared/constants/app-state.ts new file mode 100644 index 000000000000..82424edfe57f --- /dev/null +++ b/shared/constants/app-state.ts @@ -0,0 +1,20 @@ +import { TraceName } from '../lib/trace'; +import { MetaMetricsEventName } from './metametrics'; + +export enum AccountOverviewTabKey { + Tokens = 'tokens', + Nfts = 'nfts', + Activity = 'activity', +} + +export const ACCOUNT_OVERVIEW_TAB_KEY_TO_METAMETRICS_EVENT_NAME_MAP = { + [AccountOverviewTabKey.Tokens]: MetaMetricsEventName.TokenScreenOpened, + [AccountOverviewTabKey.Nfts]: MetaMetricsEventName.NftScreenOpened, + [AccountOverviewTabKey.Activity]: MetaMetricsEventName.ActivityScreenOpened, +} as const; + +export const ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP = { + [AccountOverviewTabKey.Tokens]: TraceName.AccountOverviewAssetListTab, + [AccountOverviewTabKey.Nfts]: TraceName.AccountOverviewNftsTab, + [AccountOverviewTabKey.Activity]: TraceName.AccountOverviewActivityTab, +} as const; diff --git a/shared/constants/bridge.ts b/shared/constants/bridge.ts index e87b0689777f..ed3b21c6a581 100644 --- a/shared/constants/bridge.ts +++ b/shared/constants/bridge.ts @@ -20,3 +20,7 @@ export const BRIDGE_API_BASE_URL = process.env.BRIDGE_USE_DEV_APIS : BRIDGE_PROD_API_BASE_URL; export const BRIDGE_CLIENT_ID = 'extension'; + +export const ETH_USDT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7'; +export const METABRIDGE_ETHEREUM_ADDRESS = + '0x0439e60F02a8900a951603950d8D4527f400C3f1'; diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 103a24c2463d..760e3c26a31f 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -1,6 +1,18 @@ +import { Json } from '@metamask/utils'; import type { EnvironmentType } from './app'; import { LedgerTransportTypes } from './hardware-wallets'; +type JsonWithUndefined = + | null + | boolean + | number + | string + | undefined + | Json[] + | { + [prop: string]: Json; + }; + /** * Used to attach context of where the user was at in the application when the * event was triggered. Also included as full details of the current page in @@ -92,13 +104,14 @@ export type MetaMetricsEventPayload = { /** * Custom values to track. Keys in this object must be `snake_case`. */ - properties?: object; + properties?: Record; + /** * Sensitive values to track. These properties will be sent in an additional * event that excludes the user's `metaMetricsId`. Keys in this object must be * in `snake_case`. */ - sensitiveProperties?: object; + sensitiveProperties?: Record; /** * Amount of currency that the event creates in revenue for MetaMask. */ @@ -130,6 +143,13 @@ export type MetaMetricsEventPayload = { isDuplicateAnonymizedEvent?: boolean; }; +export type UnsanitizedMetaMetricsEventPayload = Omit< + MetaMetricsEventPayload, + 'properties' +> & { + properties?: Record; +}; + export type MetaMetricsEventOptions = { /** * Whether or not the event happened during the opt-in workflow. @@ -205,13 +225,13 @@ export type MetaMetricsEventFragment = { /** * Custom values to track. Keys in this object must be `snake_case`. */ - properties?: object; + properties?: Record; /** * Sensitive values to track. These properties will be sent in an additional * event that excludes the user's `metaMetricsId`. Keys in this object must be * in `snake_case`. */ - sensitiveProperties?: object; + sensitiveProperties?: Record; /** * Amount of currency that the event creates in revenue for MetaMask. */ @@ -570,6 +590,10 @@ export enum MetaMetricsUserTrait { * Identified when the user changes token sort order on asset-list */ TokenSortPreference = 'token_sort_preference', + /** + * Identifies if the Privacy Mode is enabled + */ + PrivacyModeEnabled = 'privacy_mode_toggle', } /** @@ -607,6 +631,7 @@ export enum MetaMetricsEventName { AccountRenamed = 'Account Renamed', AccountsSyncAdded = 'Accounts Sync Added', AccountsSyncNameUpdated = 'Accounts Sync Name Updated', + AccountsSyncErroneousSituation = 'Accounts Sync Erroneous Situation', ActivityDetailsOpened = 'Activity Details Opened', ActivityDetailsClosed = 'Activity Details Closed', AnalyticsPreferenceSelected = 'Analytics Preference Selected', @@ -858,6 +883,7 @@ export enum QueueType { } export enum MetaMetricsEventAccountImportType { + // eslint-disable-next-line @typescript-eslint/no-shadow Json = 'json', PrivateKey = 'private_key', Srp = 'srp', @@ -926,6 +952,7 @@ export enum MetaMetricsNetworkEventSource { Dapp = 'dapp', DeprecatedNetworkModal = 'deprecated_network_modal', NewAddNetworkFlow = 'new_add_network_flow', + Bridge = 'bridge', } export enum MetaMetricsSwapsEventSource { diff --git a/shared/constants/mmi-controller.ts b/shared/constants/mmi-controller.ts index 83998fe6e7b9..389734305e1c 100644 --- a/shared/constants/mmi-controller.ts +++ b/shared/constants/mmi-controller.ts @@ -23,9 +23,11 @@ import { AppStateController } from '../../app/scripts/controllers/app-state-cont // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import AccountTrackerController from '../../app/scripts/controllers/account-tracker-controller'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import MetaMetricsController from '../../app/scripts/controllers/metametrics'; +import MetaMetricsController, { + MetaMetricsControllerGetStateAction, + // TODO: Remove restricted import + // eslint-disable-next-line import/no-restricted-paths +} from '../../app/scripts/controllers/metametrics-controller'; // Unique name for the controller const controllerName = 'MMIController'; @@ -47,7 +49,8 @@ export type AllowedActions = | NetworkControllerGetStateAction | NetworkControllerSetActiveNetworkAction | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerGetNetworkConfigurationByChainId; + | NetworkControllerGetNetworkConfigurationByChainId + | MetaMetricsControllerGetStateAction; /** * Messenger type for the {@link MMIController}. diff --git a/shared/constants/security-provider.ts b/shared/constants/security-provider.ts index 082f68aa88de..82f8814da9da 100644 --- a/shared/constants/security-provider.ts +++ b/shared/constants/security-provider.ts @@ -32,7 +32,7 @@ export enum BlockaidReason { approvalFarming = 'approval_farming', /** Malicious signature on Blur order */ blurFarming = 'blur_farming', - /** A known malicous site invoked that transaction */ + /** A known malicious site invoked that transaction */ maliciousDomain = 'malicious_domain', /** Malicious signature on a Permit order */ permitFarming = 'permit_farming', @@ -57,6 +57,8 @@ export enum BlockaidReason { errored = 'Error', notApplicable = 'NotApplicable', inProgress = 'validation_in_progress', + checkingChain = 'CheckingChain', + chainNotSupported = 'ChainNotSupported', } export enum BlockaidResultType { @@ -110,6 +112,8 @@ export const SECURITY_PROVIDER_EXCLUDED_TRANSACTION_TYPES = [ TransactionType.swap, TransactionType.swapApproval, TransactionType.swapAndSend, + TransactionType.bridgeApproval, + TransactionType.bridge, ]; export const LOADING_SECURITY_ALERT_RESPONSE: SecurityAlertResponse = { @@ -117,6 +121,17 @@ export const LOADING_SECURITY_ALERT_RESPONSE: SecurityAlertResponse = { reason: BlockaidReason.inProgress, }; +export const SECURITY_ALERT_RESPONSE_CHECKING_CHAIN: SecurityAlertResponse = { + result_type: BlockaidResultType.Loading, + reason: BlockaidReason.checkingChain, +}; + +export const SECURITY_ALERT_RESPONSE_CHAIN_NOT_SUPPORTED: SecurityAlertResponse = + { + result_type: BlockaidResultType.Benign, + reason: BlockaidReason.chainNotSupported, + }; + export enum SecurityAlertSource { /** Validation performed remotely using the Security Alerts API. */ API = 'api', diff --git a/shared/lib/confirmation.utils.test.ts b/shared/lib/confirmation.utils.test.ts new file mode 100644 index 000000000000..552d78827a2e --- /dev/null +++ b/shared/lib/confirmation.utils.test.ts @@ -0,0 +1,208 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { ApprovalType } from '@metamask/controller-utils'; +import { + shouldUseRedesignForTransactions, + shouldUseRedesignForSignatures, +} from './confirmation.utils'; + +describe('confirmation.utils', () => { + describe('shouldUseRedesignForTransactions', () => { + const supportedTransactionTypes = [ + TransactionType.contractInteraction, + TransactionType.deployContract, + TransactionType.tokenMethodApprove, + TransactionType.tokenMethodIncreaseAllowance, + TransactionType.tokenMethodSetApprovalForAll, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, + ]; + + const unsupportedTransactionType = TransactionType.swap; + + describe('when user setting is enabled', () => { + it('should return true for supported transaction types', () => { + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer mode disabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported transaction types', () => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: unsupportedTransactionType, + isRedesignedTransactionsUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer mode disabled + }), + ).toBe(false); + }); + }); + + describe('when developer mode is enabled', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return true for supported transaction types when ENABLE_CONFIRMATION_REDESIGN is true', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported transaction types when developer setting is enabled', () => { + supportedTransactionTypes.forEach((transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported transaction types even if developer mode is enabled', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: unsupportedTransactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(false); + }); + }); + + describe('when both user setting and developer mode are disabled', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return false for all transaction types', () => { + [...supportedTransactionTypes, unsupportedTransactionType].forEach( + (transactionType) => { + expect( + shouldUseRedesignForTransactions({ + transactionMetadataType: transactionType, + isRedesignedTransactionsUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(false); + }, + ); + }); + }); + }); + + describe('shouldUseRedesignForSignatures', () => { + const originalEnv = process.env; + + const supportedSignatureApprovalTypes = [ + ApprovalType.EthSignTypedData, + ApprovalType.PersonalSign, + ]; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return true for supported approval types when user setting is enabled', () => { + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported approval types when developer mode is enabled via env', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'true'; + + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(true); + }); + }); + + it('should return true for supported approval types when developer setting is enabled', () => { + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(true); + }); + }); + + it('should return false for unsupported approval types', () => { + const unsupportedApprovalType = ApprovalType.AddEthereumChain; + + expect( + shouldUseRedesignForSignatures({ + approvalType: unsupportedApprovalType, + isRedesignedSignaturesUserSettingEnabled: true, // user setting enabled + isRedesignedConfirmationsDeveloperEnabled: true, // developer setting enabled + }), + ).toBe(false); + }); + + it('should return false when both user setting and developer mode are disabled', () => { + process.env.ENABLE_CONFIRMATION_REDESIGN = 'false'; + + supportedSignatureApprovalTypes.forEach((approvalType) => { + expect( + shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled: false, // user setting disabled + isRedesignedConfirmationsDeveloperEnabled: false, // developer setting disabled + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/shared/lib/confirmation.utils.ts b/shared/lib/confirmation.utils.ts new file mode 100644 index 000000000000..24c5f258a5d0 --- /dev/null +++ b/shared/lib/confirmation.utils.ts @@ -0,0 +1,169 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { ApprovalType } from '@metamask/controller-utils'; + +/* eslint-disable jsdoc/require-param, jsdoc/check-param-names */ + +/** List of signature approval types that support the redesigned confirmation flow */ +const REDESIGN_SIGNATURE_APPROVAL_TYPES = [ + ApprovalType.EthSignTypedData, + ApprovalType.PersonalSign, +]; + +/** List of transaction types that support the redesigned confirmation flow for users */ +const REDESIGN_USER_TRANSACTION_TYPES = [ + TransactionType.contractInteraction, + TransactionType.deployContract, + TransactionType.tokenMethodApprove, + TransactionType.tokenMethodIncreaseAllowance, + TransactionType.tokenMethodSetApprovalForAll, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + TransactionType.tokenMethodSafeTransferFrom, + TransactionType.simpleSend, +]; + +/** List of transaction types that support the redesigned confirmation flow for developers */ +const REDESIGN_DEV_TRANSACTION_TYPES = [...REDESIGN_USER_TRANSACTION_TYPES]; + +/** + * Determines whether to use the redesigned confirmation flow for a given transaction + * based on user settings and developer mode + * + * @param opts.transactionMetadataType - The type of transaction to check + * @param opts.isRedesignedTransactionsUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param opts.isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + */ +export function shouldUseRedesignForTransactions({ + transactionMetadataType, + isRedesignedTransactionsUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, +}: { + transactionMetadataType?: TransactionType; + isRedesignedTransactionsUserSettingEnabled: boolean; + isRedesignedConfirmationsDeveloperEnabled: boolean; +}): boolean { + return ( + shouldUseRedesignForTransactionsUserMode( + isRedesignedTransactionsUserSettingEnabled, + transactionMetadataType, + ) || + shouldUseRedesignForTransactionsDeveloperMode( + isRedesignedConfirmationsDeveloperEnabled, + transactionMetadataType, + ) + ); +} + +/** + * Determines whether to use the redesigned confirmation flow for a given signature + * based on user settings and developer mode + * + * @param opts.approvalType - The type of signature approval to check + * @param opts.isRedesignedSignaturesUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param opts.isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + */ +export function shouldUseRedesignForSignatures({ + approvalType, + isRedesignedSignaturesUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, +}: { + approvalType?: ApprovalType; + isRedesignedSignaturesUserSettingEnabled: boolean; + isRedesignedConfirmationsDeveloperEnabled: boolean; +}): boolean { + const isRedesignedConfirmationsDeveloperSettingEnabled = + process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || + isRedesignedConfirmationsDeveloperEnabled; + + if (!isCorrectSignatureApprovalType(approvalType)) { + return false; + } + + return ( + isRedesignedSignaturesUserSettingEnabled || + isRedesignedConfirmationsDeveloperSettingEnabled + ); +} + +/** + * Checks if an redesign approval type is supported for signature redesign + * + * @param approvalType - The type of approval to check + */ +export function isCorrectSignatureApprovalType( + approvalType?: ApprovalType, +): boolean { + if (!approvalType) { + return false; + } + + return REDESIGN_SIGNATURE_APPROVAL_TYPES.includes(approvalType); +} + +/** + * Checks if a redesigned transaction type is supported in developer mode + * + * @param transactionMetadataType - The type of transaction to check + */ +export function isCorrectDeveloperTransactionType( + transactionMetadataType?: TransactionType, +): boolean { + if (!transactionMetadataType) { + return false; + } + + return REDESIGN_DEV_TRANSACTION_TYPES.includes(transactionMetadataType); +} + +/** + * Checks if a redesigned transaction type is supported in user mode + * + * @param transactionMetadataType - The type of transaction to check + */ +function isCorrectUserTransactionType( + transactionMetadataType?: TransactionType, +): boolean { + if (!transactionMetadataType) { + return false; + } + + return REDESIGN_USER_TRANSACTION_TYPES.includes(transactionMetadataType); +} + +/** + * Determines if the redesigned confirmation flow should be used for transactions + * when in developer mode + * + * @param isRedesignedConfirmationsDeveloperEnabled - Whether developer mode is enabled + * @param transactionMetadataType - The type of transaction to check + */ +function shouldUseRedesignForTransactionsDeveloperMode( + isRedesignedConfirmationsDeveloperEnabled: boolean, + transactionMetadataType?: TransactionType, +): boolean { + const isDeveloperModeEnabled = + process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || + isRedesignedConfirmationsDeveloperEnabled; + + return ( + isDeveloperModeEnabled && + isCorrectDeveloperTransactionType(transactionMetadataType) + ); +} + +/** + * Determines if the redesigned confirmation flow should be used for transactions + * when in user mode + * + * @param isRedesignedTransactionsUserSettingEnabled - Whether the user has enabled the redesigned flow + * @param transactionMetadataType - The type of transaction to check + */ +function shouldUseRedesignForTransactionsUserMode( + isRedesignedTransactionsUserSettingEnabled: boolean, + transactionMetadataType?: TransactionType, +): boolean { + return ( + isRedesignedTransactionsUserSettingEnabled && + isCorrectUserTransactionType(transactionMetadataType) + ); +} diff --git a/shared/lib/trace.ts b/shared/lib/trace.ts index 1dd50b736222..0d58ddcdcfcc 100644 --- a/shared/lib/trace.ts +++ b/shared/lib/trace.ts @@ -10,6 +10,9 @@ import { log as sentryLogger } from '../../app/scripts/lib/setupSentry'; */ export enum TraceName { AccountList = 'Account List', + AccountOverviewAssetListTab = 'Account Overview Asset List Tab', + AccountOverviewNftsTab = 'Account Overview Nfts Tab', + AccountOverviewActivityTab = 'Account Overview Activity Tab', BackgroundConnect = 'Background Connect', DeveloperTest = 'Developer Test', FirstRender = 'First Render', diff --git a/shared/modules/bridge-utils/balance.test.ts b/shared/modules/bridge-utils/balance.test.ts new file mode 100644 index 000000000000..15cf1f246c8b --- /dev/null +++ b/shared/modules/bridge-utils/balance.test.ts @@ -0,0 +1,138 @@ +import { BigNumber } from 'ethers'; +import { zeroAddress } from 'ethereumjs-util'; +import { createTestProviderTools } from '../../../test/stub/provider'; +import { CHAIN_IDS } from '../../constants/network'; +import { Numeric } from '../Numeric'; +import * as tokenutil from '../../lib/token-util'; +import { calcLatestSrcBalance, hasSufficientBalance } from './balance'; + +const mockGetBalance = jest.fn(); +jest.mock('@ethersproject/providers', () => { + return { + Web3Provider: jest.fn().mockImplementation(() => { + return { + getBalance: mockGetBalance, + }; + }), + }; +}); + +describe('balance', () => { + beforeEach(() => { + jest.clearAllMocks(); + const { provider } = createTestProviderTools({ + networkId: 'Ethereum', + chainId: CHAIN_IDS.MAINNET, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + global.ethereumProvider = provider as any; + }); + + describe('calcLatestSrcBalance', () => { + it('should return the ERC20 token balance', async () => { + const mockFetchTokenBalance = jest.spyOn(tokenutil, 'fetchTokenBalance'); + mockFetchTokenBalance.mockResolvedValueOnce(BigNumber.from('100')); + + expect( + await calcLatestSrcBalance( + global.ethereumProvider, + '0x123', + '0x456', + '0x789', + ), + ).toStrictEqual(Numeric.from(100, 10)); + expect(mockFetchTokenBalance).toHaveBeenCalledTimes(1); + expect(mockFetchTokenBalance).toHaveBeenCalledWith( + '0x456', + '0x123', + global.ethereumProvider, + ); + }); + + it('should return the native asset balance', async () => { + mockGetBalance.mockImplementation(() => { + return BigNumber.from(100); + }); + + expect( + await calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + zeroAddress(), + '0x789', + ), + ).toStrictEqual(Numeric.from(100, 10)); + expect(mockGetBalance).toHaveBeenCalledTimes(1); + expect(mockGetBalance).toHaveBeenCalledWith( + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + ); + }); + + it('should return undefined if token address and chainId are undefined', async () => { + const mockFetchTokenBalance = jest.spyOn(tokenutil, 'fetchTokenBalance'); + expect( + await calcLatestSrcBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', + undefined as never, + undefined as never, + ), + ).toStrictEqual(undefined); + expect(mockFetchTokenBalance).not.toHaveBeenCalled(); + expect(mockGetBalance).not.toHaveBeenCalled(); + }); + }); + + describe('hasSufficientBalance', () => { + it('should return true if user has sufficient balance', async () => { + mockGetBalance.mockImplementation(() => { + return BigNumber.from('10000000000000000000'); + }); + const mockFetchTokenBalance = jest.spyOn(tokenutil, 'fetchTokenBalance'); + mockFetchTokenBalance.mockResolvedValueOnce( + BigNumber.from('10000000000000000001'), + ); + + expect( + await hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + zeroAddress(), + '10000000000000000000', + '0x1', + ), + ).toBe(true); + + expect( + await hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(true); + }); + + it('should return false if user has native assets but insufficient ERC20 src tokens', async () => { + mockGetBalance.mockImplementation(() => { + return BigNumber.from('10000000000000000000'); + }); + const mockFetchTokenBalance = jest.spyOn(tokenutil, 'fetchTokenBalance'); + mockFetchTokenBalance.mockResolvedValueOnce( + BigNumber.from('9000000000000000000'), + ); + + expect( + await hasSufficientBalance( + global.ethereumProvider, + '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + '0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1', + '10000000000000000000', + '0x1', + ), + ).toBe(false); + }); + }); +}); diff --git a/shared/modules/bridge-utils/balance.ts b/shared/modules/bridge-utils/balance.ts new file mode 100644 index 000000000000..2ba5834d8df9 --- /dev/null +++ b/shared/modules/bridge-utils/balance.ts @@ -0,0 +1,53 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { Hex } from '@metamask/utils'; +import { zeroAddress } from 'ethereumjs-util'; +import { getAddress } from 'ethers/lib/utils'; +import { HttpProvider } from '../../../types/global'; +import { fetchTokenBalance } from '../../lib/token-util'; +import { Numeric } from '../Numeric'; + +export const calcLatestSrcBalance = async ( + provider: HttpProvider, + selectedAddress: string, + tokenAddress: string, + chainId: Hex, +): Promise => { + if (tokenAddress && chainId) { + if (tokenAddress === zeroAddress()) { + const ethersProvider = new Web3Provider(provider); + return Numeric.from( + ( + await ethersProvider.getBalance(getAddress(selectedAddress)) + ).toString(), + 10, + ); + } + return Numeric.from( + ( + await fetchTokenBalance(tokenAddress, selectedAddress, provider) + ).toString(), + 10, + ); + } + return undefined; +}; + +export const hasSufficientBalance = async ( + provider: unknown, + selectedAddress: string, + tokenAddress: string, + fromTokenAmount: string, + chainId: Hex, +) => { + const srcTokenBalance = await calcLatestSrcBalance( + provider as HttpProvider, + selectedAddress, + tokenAddress, + chainId, + ); + + return ( + srcTokenBalance?.greaterThanOrEqualTo(Numeric.from(fromTokenAmount, 10)) ?? + false + ); +}; diff --git a/shared/modules/fetch-with-timeout.test.ts b/shared/modules/fetch-with-timeout.test.ts index 2129db1c30fa..11ccd145488e 100644 --- a/shared/modules/fetch-with-timeout.test.ts +++ b/shared/modules/fetch-with-timeout.test.ts @@ -45,6 +45,23 @@ describe('getFetchWithTimeout', () => { }).rejects.toThrow('The user aborted a request.'); }); + it('should abort the request when a custom signal aborts', async () => { + nock('https://api.infura.io') + .get('/moon') + .delay(SECOND * 2) + .reply(200, '{"moon": "2012-12-21T11:11:11Z"}'); + const abortSignal = new window.AbortController(); + + const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); + + setTimeout(() => abortSignal.abort('Request aborted'), SECOND * 2); + await expect(async () => { + await fetchWithTimeout('https://api.infura.io/moon', { + signal: abortSignal.signal, + }).then((r) => r.json()); + }).rejects.toThrow('The user aborted a request.'); + }); + it('throws on invalid timeout', async () => { await expect(() => getFetchWithTimeout(-1)).toThrow( 'Must specify positive integer timeout.', diff --git a/shared/modules/fetch-with-timeout.ts b/shared/modules/fetch-with-timeout.ts index f5c6d5384012..f6caedd4afa5 100644 --- a/shared/modules/fetch-with-timeout.ts +++ b/shared/modules/fetch-with-timeout.ts @@ -21,10 +21,20 @@ const getFetchWithTimeout = memoize((timeout = SECOND * 30) => { opts?: RequestInit, ): Promise { const abortController = new window.AbortController(); - const { signal } = abortController; + + // Add the provided signal to the list of signals that can abort the request + const abortSignals = [abortController.signal]; + if (opts?.signal) { + abortSignals.push(opts.signal); + } + + const combinedAbortController = new AbortController(); + const abortHandler = () => combinedAbortController.abort(); + abortSignals.forEach((sig) => sig.addEventListener('abort', abortHandler)); + const f = window.fetch(url, { ...opts, - signal, + signal: combinedAbortController.signal, }); const timer = setTimeout(() => abortController.abort(), timeout); @@ -33,6 +43,9 @@ const getFetchWithTimeout = memoize((timeout = SECOND * 30) => { return await f; } finally { clearTimeout(timer); + abortSignals.forEach((sig) => + sig.removeEventListener('abort', abortHandler), + ); } }; }); diff --git a/shared/modules/network.utils.test.ts b/shared/modules/network.utils.test.ts index ee4ef3f8399e..783d764f8825 100644 --- a/shared/modules/network.utils.test.ts +++ b/shared/modules/network.utils.test.ts @@ -3,6 +3,7 @@ import { isSafeChainId, isPrefixedFormattedHexString, isTokenDetectionEnabledForNetwork, + convertNetworkId, } from './network.utils'; describe('network utils', () => { @@ -83,4 +84,52 @@ describe('network utils', () => { expect(isTokenDetectionEnabledForNetwork(undefined)).toBe(false); }); }); + + describe('convertNetworkId', () => { + it('returns decimal strings for postive integer number values', () => { + expect(convertNetworkId(0)).toStrictEqual('0'); + expect(convertNetworkId(123)).toStrictEqual('123'); + expect(convertNetworkId(1337)).toStrictEqual('1337'); + }); + + it('returns null for negative numbers', () => { + expect(convertNetworkId(-1)).toStrictEqual(null); + }); + + it('returns null for non integer numbers', () => { + expect(convertNetworkId(0.1)).toStrictEqual(null); + expect(convertNetworkId(1.1)).toStrictEqual(null); + }); + + it('returns null for NaN', () => { + expect(convertNetworkId(Number.NaN)).toStrictEqual(null); + }); + + it('returns decimal strings for strict valid hex values', () => { + expect(convertNetworkId('0x0')).toStrictEqual('0'); + expect(convertNetworkId('0x1')).toStrictEqual('1'); + expect(convertNetworkId('0x539')).toStrictEqual('1337'); + }); + + it('returns null for invalid hex values', () => { + expect(convertNetworkId('0xG')).toStrictEqual(null); + expect(convertNetworkId('0x@')).toStrictEqual(null); + expect(convertNetworkId('0xx1')).toStrictEqual(null); + }); + + it('returns the value as is if already a postive decimal string', () => { + expect(convertNetworkId('0')).toStrictEqual('0'); + expect(convertNetworkId('1')).toStrictEqual('1'); + expect(convertNetworkId('1337')).toStrictEqual('1337'); + }); + + it('returns null for negative number strings', () => { + expect(convertNetworkId('-1')).toStrictEqual(null); + }); + + it('returns null for non integer number strings', () => { + expect(convertNetworkId('0.1')).toStrictEqual(null); + expect(convertNetworkId('1.1')).toStrictEqual(null); + }); + }); }); diff --git a/shared/modules/network.utils.ts b/shared/modules/network.utils.ts index 8985bdcf7f5a..764a34bb8520 100644 --- a/shared/modules/network.utils.ts +++ b/shared/modules/network.utils.ts @@ -78,16 +78,16 @@ function isSafeInteger(value: unknown): value is number { * as either a number, a decimal string, or a 0x-prefixed hex string. * * @param value - The network ID to convert, in an unknown format. - * @returns A valid network ID (as a decimal string) - * @throws If the given value cannot be safely parsed. + * @returns A valid network ID (as a decimal string) or null if + * the given value cannot be parsed. */ -export function convertNetworkId(value: unknown): string { - if (typeof value === 'number' && !Number.isNaN(value)) { +export function convertNetworkId(value: unknown): string | null { + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) { return `${value}`; } else if (isStrictHexString(value)) { return `${convertHexToDecimal(value)}`; } else if (typeof value === 'string' && /^\d+$/u.test(value)) { return value; } - throw new Error(`Cannot parse as a valid network ID: '${value}'`); + return null; } diff --git a/test/data/bridge/dummy-quotes.ts b/test/data/bridge/dummy-quotes.ts new file mode 100644 index 000000000000..3328960a9b73 --- /dev/null +++ b/test/data/bridge/dummy-quotes.ts @@ -0,0 +1,4005 @@ +export const DummyQuotesNoApproval = { + OP_0_005_ETH_TO_ARB: [ + { + quote: { + requestId: 'be448070-7849-4d14-bb35-8dcdaf7a4d69', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4927504629714929', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4927504629714929', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000006c5a39b10ad191481350ef776ac5fe6ef47965741f6f7a4734bf784bf3ae3f24520000a4b100149ae8681b4efd66f30743ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b710000000000000000000000000000000000000000a98f352a08c48ebdbb94ced425b06cf909d74f41e3dfea3c72061a0d88423d867545801fa572404f63d531757a740fd1fd0f12a89217856e4d59f771328fd4bb1c', + gasLimit: 155983, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: '7e33348d-726c-4f91-b8a0-152828c565ff', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4955000000000000', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4955000000000000', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11e6cbb0321441', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011e6cbb032144100000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000003e414d53077000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002005828fdd30895f9a246394c8876a30c0a6debe54a9aaed574de790d6e9fe2c1f60000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000002352785194410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080c2800c127d224651f5dd852ca8a4abd8a3804aff686dcb794b566b7ea694865f1edd9965ed1810678d845410548d5cae2acc3f8ea98c936e7e566923c1229d1c', + gasLimit: 515457, + }, + estimatedProcessingTimeInSeconds: 51, + }, + { + quote: { + requestId: 'f49a25e4-e396-40d8-a2bf-95ef5ec03d9f', + srcChainId: 10, + srcTokenAmount: '4956250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId: 42161, + destTokenAmount: '4852705984263432', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['hop'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'hop', + displayName: 'Hop', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/hop.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641.2', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2641', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '4956250000000000', + destAmount: '4852705984263432', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000006ef81a18e1e432c289dc0d1a670b78e8bbf9aa350000000000000000000000006ef81a18e1e432c289dc0d1a670b78e8bbf9aa35000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000044161be542b8c35a6e235f7b26c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000a4b1000000000000000000005eb7c7b0c1c6000000000000000000113dac153d909300000000000000000000000000000000000000000000000000000000d7e5c567b37ba289b97e1a2eb3cda9ebd9811a004255d1fe44b1ccb372b6b41c3aea5aa0d70ad19378485c6e31e10df0eb3a7f957c8441d6474853e81acd4a991b', + gasLimit: 338772, + }, + estimatedProcessingTimeInSeconds: 56, + }, + { + quote: { + requestId: '625e7eb4-065c-4661-90d1-d94f6eb4adcc', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + srcTokenAmount: '4956250000000000', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + destTokenAmount: '4852928026153929', + feeData: { + metabridge: { + amount: '43750000000000', + asset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['hop'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'hop', + displayName: 'Hop', + icon: 'https://bridgelogos.s3.ap-south-1.amazonaws.com/hop.png', + }, + srcAsset: { + chainId: 10, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 42161, + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://assets.polygon.technology/tokenAssets/eth.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/eth.svg', + chainAgnosticId: null, + }, + srcAmount: '4956250000000000', + destAmount: '4852928026153929', + }, + ], + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x11c37937e08000', + data: '0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011c37937e0800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000119baee0ab04000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000027ca57357c00000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a5187000000000000000000000000000000000000000000000000000000000000014800000011c8a6285e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000086ca30bef97fb651b8d866d45503684b90cb331200000000000000000000000000000000000000000000000000119baee0ab0400000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000000000000000000000000000000060dcd8031258000000000000000000000000000000000000000000000000001185250b108f80000000000000000000000000000000000000000000000000000001924963f1dd00000000000000000000000000000000000000000000000000112728d1e75e79000000000000000000000000000000000000000000000000000001924963f1dd00000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000007d6791da46aae617c18c7b5987f2f25e8bc35083c3c973e71bfa0f7bd70088b0558f63bef3e55bc881d14bd276f41690d864e3d0380bfd4ac557b8b9dde896c51c', + gasLimit: 414453, + }, + estimatedProcessingTimeInSeconds: 60, + }, + ], +}; + +export const DummyQuotesWithApproval = { + ETH_11_USDC_TO_ARB: [ + { + quote: { + requestId: '0cd5caf6-9844-465b-89ad-9c89b639f432', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10876521', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10876521', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + gasLimit: 209923, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: 'f197aa3f-a1ed-46fe-8d5f-80866a860a9b', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10803750', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10803750', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000001e4bab657d800000000000000000000000000000000000000000000000000000000000000202210951480e39a2501daae3e15f254f5431a326e0e6ceb775feb685843012458000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d627269646765000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5f6d5c0d29059e2e6f6c5f03c44aa894aa0b2d888b46f8844d855a89c83b372148e183ab8a90043501865ea48326990c0783e195c85330e0f2f12db7953df991b', + gasLimit: 430753, + }, + estimatedProcessingTimeInSeconds: 989.412, + }, + { + quote: { + requestId: '4a954e96-a11d-4879-a1c0-54b24ae14ebb', + srcChainId: 1, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10903640', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0002000400080016', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10903640', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x1be3c54359bf', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000003e414d53077000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002002df432cfa7217ed9dc0aae2d324260c237970c8f9c97439d3faf2a000347d96e000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001be3c54359bf0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f4e69f41f7ff5673a2df84fb3f246a59101ad7cc25219d596995a85d89c4a1684e9d9e9b2fb8775295686d52b5189794fc16a1dea348e5487eddeeaaebaec7441b', + gasLimit: 634343, + }, + estimatedProcessingTimeInSeconds: 197, + }, + { + quote: { + requestId: '32ad49ef-d710-4fb9-904a-a5d9cc95bd78', + srcChainId: 1, + srcAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + srcTokenAmount: '10903750', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10503750', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 1, + destChainId: 42161, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: 'USDC', + }, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '10903750', + destAmount: '10503750', + }, + ], + }, + approval: { + chainId: 1, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 56349, + }, + trade: { + chainId: 1, + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000e800000197b7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000002f0cd4583d78e68889d534cc878714e5c66fd0a2867f348f8e969d35f7fddfde3d108c064ffb227225c3e4faaf59aa5df6a967534e63143227b9a9a00c4dd6671c', + gasLimit: 285725, + }, + estimatedProcessingTimeInSeconds: 1020, + }, + ], + ARB_11_USDC_TO_ETH: [ + { + quote: { + requestId: 'edbef62a-d3e6-4b33-aad5-9cdb81f85f53', + srcChainId: 42161, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '10903750', + destChainId: 1, + destAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: 'USDC', + }, + destTokenAmount: '7821920', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 1, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 1, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USDCoin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: 'USDC', + }, + srcAmount: '10903750', + destAmount: '7821920', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 116676, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002f06660000000000000000000000000000000000000000000000000459142ce9a5e109850e867866af8a8e49ef715600981974e0b7f4e48c2e17044dd8921fac00ee87aae63468a52727e978cdf9debbe4cef61ae994d103092e881c', + gasLimit: 409790, + }, + estimatedProcessingTimeInSeconds: 1140, + }, + ], + ARB_11_USDC_TO_OP: [ + { + quote: { + requestId: 'dc63e7e6-dc9b-4aa8-80bb-714192ecd801', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10897534', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10897534', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000902340ab8fc7e68ebe53823219c5fe6ef47965741f6f7a4734bf784bf3ae3f2452af88d065e77c8cc2239327c5edb3a432268e583100000000000000000000000000a660c60000000a0002067997b930636705a29bffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000ec7648fdf42336c0860c532fb076be8938251a3349d81d611f21e620d8e1ab9f5719d1f40aac527243e6883beb9d22af9a584b5fb419af9d0970804b97976cbb1c', + gasLimit: 734160, + }, + estimatedProcessingTimeInSeconds: 51, + }, + { + quote: { + requestId: 'dd718a05-ee10-4ec4-99f2-9bc3676640a1', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10903640', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10903640', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x132018b59ef1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000003e414d5307700000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200c564a18849d43f86f9dde5b38ad12801f40e4f909559b6195db0903f7398ef75000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000132018b59ef10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759f000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d28b64c753bb8cd4ac8863f4da513edc5d10a619ea4af9623a5f8fdb86f4296a4735ddf5981acb464fc0f35741dd26fb730eb9b5323586499631edf7870587ed1b', + gasLimit: 1232971, + }, + estimatedProcessingTimeInSeconds: 33, + }, + { + quote: { + requestId: 'f07aefdc-2be7-4c41-a0ab-87ac2ec16e3a', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 10, + destTokenAmount: '10803750', + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '10903750', + destAmount: '10803750', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000001e4bab657d80000000000000000000000000000000000000000000000000000000000000020cc4b6b89255288b4450ce670297679230238eca5fc5572f0b3fdca8fcd60081f000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d627269646765000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000454801e91c4c31ee115b5b1dadfa6abefc87bb88079879d7a68d6ac78ac9eb917678a2589c527e4819b0d2fd2bd3bf071d761f64f85ff8d77b90cca67ccbb5f01b', + gasLimit: 967319, + }, + estimatedProcessingTimeInSeconds: 1073, + }, + { + quote: { + requestId: 'ef05128f-c693-4d4a-adec-2b103f931a43', + srcChainId: 42161, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '10903750', + destChainId: 10, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10703750', + feeData: { + metabridge: { + amount: '96250', + asset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '10903750', + destAmount: '10703750', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf00000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000030d40000000000000000000000000000000000000000000000000a798e0436c3787ba6e14fd44badd42b591886ba135ebdb6a441494c203e848c61c89a6a55b51f66c46023f5afda568446168e121a4acdfa1c60d7b8c31f0507c1c', + gasLimit: 783830, + }, + estimatedProcessingTimeInSeconds: 1080, + }, + { + quote: { + requestId: '4a15ec74e270a7ffc07aaad0bd59853e', + srcChainId: 42161, + srcTokenAmount: '10903750', + srcAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destChainId: 10, + destTokenAmount: '10900626', + destAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + feeData: { + metabridge: { + amount: '96250', + asset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + }, + }, + bridgeId: 'squid', + bridges: ['axelar'], + steps: [ + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Pancakeswap V3', + displayName: 'Pancakeswap V3', + }, + srcAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009320510897841, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009320510897841, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10903750', + destAmount: '10901792', + }, + { + action: 'bridge', + srcChainId: 42161, + destChainId: 10, + protocol: { + name: 'axelar', + displayName: 'Axelar', + }, + srcAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009320510897841, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:23:55.197Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009026800874075, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10901792', + destAmount: '10901792', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.0009026800874075, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10901792', + destAmount: '10902122', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.0009026800874075, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:23:55.474Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10902122', + destAmount: '10900626', + }, + ], + }, + approval: { + chainId: 42161, + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b300000000000000000000000023981fc34e69eedfe2bd9a0a9fcb0719fe09dbfc0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 280491, + }, + trade: { + chainId: 42161, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x3bcba906c4fc', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e737175696441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010e0000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa00000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf0000000000000000000000000000000000000000000000000000000000000f94846a1bc6000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c600000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000520000000000000000000000000000000000000000000000000000000000000056000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e5831000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d6660000000000000000000000000000000000000000000000000000000000a660c60000000000000000000000000000000000000000000000000000000000a614fd0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c555344430000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000086f7074696d69736d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078636531364636393337353532306162303133373763653742383866354241384334384638443636360000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009500000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000054000000000000000000000000000000000000000000000000000000000000006c000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000000000000a659200000000000000000000000000000000000000000000000000000000000a616460000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000064000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a65a6a0000000000000000000000000000000000000000000000000000000000a6107000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c3160700000000000000000000000000000000000000000000000000000000000000044a15ec74e270a7ffc07aaad0bd59853e000000000000000000000000000000004a15ec74e270a7ffc07aaad0bd59853e00000000000000000000000051039353c0cc77f171c42153ba91fbce7169a04b7d7aca7d0c6eeb24afe83301731e1c6247d68398068d4e73a5ab2d6e9abc76ed5a0fc5ddf1e6e340ee59bf3c1c', + gasLimit: 1491274, + }, + estimatedProcessingTimeInSeconds: 20, + }, + ], + OP_11_USDC_TO_ARB: [ + { + quote: { + requestId: '01fa78fd-ed49-42b3-ab0e-94c7108feea9', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10950676', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10950676', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000902340ab8f15c1311a1d882e68c5fe6ef47965741f6f7a4734bf784bf3ae3f24520b2c639c533813f4aa9d7837caf62653d097ff8500000000000000000000000000a7d8c00000a4b1000fee2f88fb6d2f6705a29bffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000e901efe0f8781a535ac71317aa666e079e2b867f1a0f1aae7db8afdf38c6f5a663f8638a8fa1f578ba4f5613853bb4ff7b831b0cfeccdcf47bb3e46feff039371c', + gasLimit: 196468, + }, + estimatedProcessingTimeInSeconds: 15, + }, + { + quote: { + requestId: '04064397-73e1-44c0-a2ed-f938e5fe62f0', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10999889', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['stargate'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'stargate', + displayName: 'StargateV2 (Fast mode)', + icon: 'https://raw.githubusercontent.com/lifinance/types/5685c638772f533edad80fcb210b4bb89e30a50f/src/assets/icons/bridges/stargate.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10999889', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x1f7968e0913f', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000003e414d5307700000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000200f02847b1891a30122096ac33055ab7e4286cae991a862dbd35a181006f45b44d0000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a7374617267617465563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000001f7968e0913f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f2452000000000000000000000000000000000000000000000000000000000000759e000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ca2568629f30c9780c694cf80af2799e1836b70fd6a221915056dacf1584d63a531f3049719aaeb572635cf719df4410100859da7b9033ba52805691cace86ef1b', + gasLimit: 619670, + }, + estimatedProcessingTimeInSeconds: 50, + }, + { + quote: { + requestId: '26d1486d-1979-4a24-b066-aa87ea6a9cbf', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destChainId: 42161, + destTokenAmount: '10900000', + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'celercircle', + displayName: 'Circle CCTP', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/circle.png', + }, + srcAsset: { + address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + chainId: 10, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + destAsset: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: 42161, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1.0007004903432404', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + }, + srcAmount: '11000000', + destAmount: '10900000', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003400000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae0000000000000000000000001231deb6f5749ef6ce6943a275a1d3e7486f4eae000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000001e4bab657d80000000000000000000000000000000000000000000000000000000000000020af6b3cbb61978d928e0a59f45df6e973d36326c48aaa054412683aba82adbed60000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b63656c6572636972636c65000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6d6574616d61736b2d6272696467650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000e2d38d411f459d41f584802754a11be4cff908688ddd18e4d274400fd0de6d9ff9a6eeb426c654afae9fdb3f99e511bb288a7246018e54432afb60be63691b', + gasLimit: 415725, + }, + estimatedProcessingTimeInSeconds: 1134, + }, + { + quote: { + requestId: '544ebf94-e5d4-4553-8c64-af881b55c6ff', + srcChainId: 10, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + srcTokenAmount: '11000000', + destChainId: 42161, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + destTokenAmount: '10600000', + feeData: { + metabridge: { + amount: '0', + asset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://media.socket.tech/tokens/all/USDC', + logoURI: 'https://media.socket.tech/tokens/all/USDC', + chainAgnosticId: null, + }, + }, + }, + bridgeId: 'socket', + bridges: ['celercircle'], + steps: [ + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'cctp', + displayName: 'Circle CCTP', + icon: 'https://movricons.s3.ap-south-1.amazonaws.com/CCTP.svg', + }, + srcAsset: { + chainId: 10, + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + destAsset: { + chainId: 42161, + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + logoURI: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + chainAgnosticId: null, + }, + srcAmount: '11000000', + destAmount: '10600000', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a5000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000000e80000018cb7dfe9d00000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000a4b100000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000000aaa45c155ccfb09c996a750c7d53dc975065d8841b58669213d7f345318d2395a5796ece3d8c0729e128bd0a33dface658f5846a3496dcfbeeca77394fe9b5a1b', + gasLimit: 290954, + }, + estimatedProcessingTimeInSeconds: 1500, + }, + { + quote: { + requestId: '389140aaaebab60eca1d15b4134c27fa', + srcChainId: 10, + srcTokenAmount: '11000000', + srcAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destChainId: 42161, + destTokenAmount: '10996548', + destAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + feeData: { + metabridge: { + amount: '0', + asset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + volatility: 0, + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + }, + }, + bridgeId: 'squid', + bridges: ['axelar'], + steps: [ + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520c', + id: '10_0x0b2c639c533813f4aa9d7837caf62653d097ff85', + symbol: 'USDC', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: ['uusdc', 'cctp-uusdc-optimism-to-noble'], + enabled: true, + createdAt: '2024-09-03T20:52:13.858Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '11000000', + destAmount: '10999308', + }, + { + action: 'swap', + srcChainId: 10, + destChainId: 10, + protocol: { + name: 'Uniswap V3', + displayName: 'Uniswap V3', + }, + srcAsset: { + _id: '66d776fd76523303f628520a', + id: '10_0x7f5c764cbc14f9669b88837ca1490cca17c31607', + symbol: 'USDC.e', + address: '0x7f5c764cbc14f9669b88837ca1490cca17c31607', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: 'USDC.e', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC.e', + subGraphIds: [], + enabled: true, + createdAt: '2024-09-03T20:52:13.857Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + subGraphOnly: false, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10999308', + destAmount: '10996775', + }, + { + action: 'bridge', + srcChainId: 10, + destChainId: 42161, + protocol: { + name: 'axelar', + displayName: 'Axelar', + }, + srcAsset: { + _id: '66d776fd76523303f6285210', + id: '10_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 10, + chain: { + _id: '66d776eb0befbcf39c0a01d5', + id: '10', + chainId: '10', + networkIdentifier: 'optimism', + chainName: 'Chain 10', + axelarChainName: 'optimism', + type: 'evm', + networkName: 'Optimism', + nativeCurrency: { + name: 'Optimism', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/optimism.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/optimism.webp', + blockExplorerUrls: ['https://optimistic.etherscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'optimism', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '2', + tokenMessenger: + '0x2B4069517957735bE00ceE0fadAE88a26365528f', + messageTransmitter: + '0x4d41f22c5a0e5c74090899e5a8fb597a8842b3e8', + }, + }, + rpcList: ['https://mainnet.optimism.io'], + internalRpc: [ + 'https://opt-mainnet.g.alchemy.com/v2/YLCHNNGouPGR8L-KViSmQ-4dCKaE6o6H', + 'https://cool-green-tree.optimism.quiknode.pro/c8f7de54a6b1d0e6a15924c5bf3aae2d6c24f0c0', + 'https://optimism-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://opt-mainnet.g.alchemy.com/v2/wDgIhJ3Yz4PvLRkQlA1k0y67dZRb146a', + 'https://opt-mainnet.g.alchemy.com/v2/E8BiF2_ABVQ5fy394vfMaM1JGkpPkIeY', + 'https://nd-739-933-380.p2pify.com/2c8029def5e92e4da7bec7f7c1c153a4', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x4200000000000000000000000000000000000006', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0x7F5c764cBc14f9669B88837ca1490cCa17c31607', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.745Z', + updatedAt: '2024-09-13T09:51:30.869Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:13.860Z', + updatedAt: '2024-10-08T21:24:40.381Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + srcAmount: '10996775', + destAmount: '10996775', + }, + { + action: 'swap', + srcChainId: 42161, + destChainId: 42161, + protocol: { + name: 'Pancakeswap V3', + displayName: 'Pancakeswap V3', + }, + srcAsset: { + _id: '66d776fb76523303f628495e', + id: '42161_0xeb466342c4d449bc9f53a865d5cb90586f405215', + symbol: 'USDC.axl', + address: '0xeb466342c4d449bc9f53a865d5cb90586f405215', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: ' USDC (Axelar)', + decimals: 6, + usdPrice: 1.001053579729135, + interchainTokenId: null, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'axlUSDC', + subGraphOnly: false, + subGraphIds: ['uusdc'], + enabled: true, + createdAt: '2024-09-03T20:52:11.583Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-configs/main/images/tokens/usdc.svg', + }, + destAsset: { + _id: '66d776fb76523303f628495c', + id: '42161_0xaf88d065e77c8cc2239327c5edb3a432268e5831', + symbol: 'USDC', + address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + chainId: 42161, + chain: { + _id: '66d776eb0befbcf39c0a01d1', + id: '42161', + chainId: '42161', + networkIdentifier: 'arbitrum', + chainName: 'Chain 42161', + axelarChainName: 'Arbitrum', + type: 'evm', + networkName: 'Arbitrum', + nativeCurrency: { + name: 'Arbitrum', + symbol: 'ETH', + decimals: 18, + icon: 'https://raw.githubusercontent.com/axelarnetwork/axelar-docs/main/public/images/chains/arbitrum.svg', + }, + chainIconURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/arbitrum.webp', + blockExplorerUrls: ['https://arbiscan.io/'], + swapAmountForGas: '2000000', + sameChainSwapsSupported: true, + squidContracts: { + squidRouter: '0xce16F69375520ab01377ce7B88f5BA8C48F8D666', + defaultCrosschainToken: + '0xEB466342C4d449BC9f53A865D5Cb90586f405215', + squidMulticall: '0xEa749Fd6bA492dbc14c24FE8A3d08769229b896c', + squidFeeCollector: + '0xd3F8F338FdAD6DEb491F0F225d09422A7a70cc45', + }, + compliance: { + trmIdentifier: 'arbitrum', + }, + boostSupported: true, + enableBoostByDefault: true, + bridges: { + axelar: { + gateway: '0xe432150cce91c13a887f7D836923d5597adD8E31', + itsService: '0xB5FB4BE02232B1bBA4dC8f81dc24C26980dE9e3C', + }, + cctp: { + cctpDomain: '3', + tokenMessenger: + '0x19330d10D9Cc8751218eaf51E8885D058642E08A', + messageTransmitter: + '0xC30362313FBBA5cf9163F0bb16a0e01f01A896ca', + }, + chainflip: { + vault: '0x79001a5e762f3befc8e5871b42f6734e00498920', + }, + }, + rpcList: ['https://arb1.arbitrum.io/rpc'], + internalRpc: [ + 'https://arb-mainnet.g.alchemy.com/v2/3IBUO-R6MAFGVxOYRHTV-Gt4Pce5jmsz', + 'https://special-wispy-theorem.arbitrum-mainnet.quiknode.pro/befd7d6b704d6477ef747f7ed39299d252994e18', + 'https://arbitrum-mainnet.infura.io/v3/273aad656cd94f9aa022e4899b87dd6c', + 'https://arb-mainnet.g.alchemy.com/v2/2YR6kg9ueaoGnMxBsvLNXqJyCrmAKC11', + 'https://arb-mainnet.g.alchemy.com/v2/3q9qfCdKJcOA2WbdqVic8jtwnQTZGwlm', + 'https://arbitrum-mainnet.core.chainstack.com/10fd48bf1e4a75d901b11b2ff2c76ada', + ], + chainNativeContracts: { + wrappedNativeToken: + '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + ensRegistry: '', + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + usdcToken: '0xff970a61a04b1ca14834a43f5de4533ebddb5cc8', + }, + feeCurrencies: [], + currencies: [], + features: [], + enabled: true, + createdAt: '2024-09-03T20:51:55.733Z', + updatedAt: '2024-09-13T09:57:11.584Z', + __v: 1, + }, + name: 'USDC', + decimals: 6, + usdPrice: 1.001053579729135, + coingeckoId: 'usd-coin', + type: 'evm', + logoURI: + 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + axelarNetworkSymbol: 'USDC', + subGraphOnly: false, + subGraphIds: [ + 'uusdc', + 'cctp-uusdc-arbitrum-to-noble', + 'btc-usdc-arb', + ], + enabled: true, + createdAt: '2024-09-03T20:52:11.579Z', + updatedAt: '2024-10-08T21:24:40.127Z', + __v: 0, + active: true, + icon: 'https://raw.githubusercontent.com/0xsquid/assets/main/images/tokens/usdc.svg', + }, + srcAmount: '10996775', + destAmount: '10996548', + }, + ], + }, + approval: { + chainId: 10, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x00', + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000a7d8c0', + gasLimit: 61865, + }, + trade: { + chainId: 10, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + value: '0x46366a86b7c6', + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000e737175696441646170746572563200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010e0000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d666000000000000000000000000000000000000000000000000000000000000a4b10000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a51870000000000000000000000000000000000000000000000000000000000000f94846a1bc60000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000a7d8c0000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000940000000000000000000000000000000000000000000000000000000000000098000000000000000000000000000000000000000000000000000000000000009e0000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ea749fd6ba492dbc14c24fe8a3d08769229b896c0000000000000000000000000000000000000000000000000000000000a7d8c00000000000000000000000000000000000000000000000000000000000a7914d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf0000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c31607000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000064000000000000000000000000ce16f69375520ab01377ce7b88f5ba8c48f8d6660000000000000000000000000000000000000000000000000000000000a7d60c0000000000000000000000000000000000000000000000000000000000a7876c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000007f5c764cbc14f9669b88837ca1490cca17c316070000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000761786c55534443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008417262697472756d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078636531364636393337353532306162303133373763653742383866354241384334384638443636360000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005700000000000000000000000000000000000000000000000000000000000000040000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f245200000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000000000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000044095ea7b300000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000032226588378236fd0c7c4053999f88ac0e5cac77000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f405215000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000000064000000000000000000000000c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000000000000000000000000000000000000000000000000000000a7cc270000000000000000000000000000000000000000000000000000000000a786890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000eb466342c4d449bc9f53a865d5cb90586f4052150000000000000000000000000000000000000000000000000000000000000004389140aaaebab60eca1d15b4134c27fa00000000000000000000000000000000389140aaaebab60eca1d15b4134c27fa0000000000000000000000004f83a94a67ba9e24e0fee7799e54ddb2575d8454082cf56a7c0292457ce280df23aa57920d0bef49660cda25b12442e1fc410a745097e2ef21491f05082aa8661b', + gasLimit: 565594, + }, + estimatedProcessingTimeInSeconds: 20, + }, + ], +}; diff --git a/test/data/mock-data.js b/test/data/mock-data.js index 4775f1dbb25e..c68d70a1fc59 100644 --- a/test/data/mock-data.js +++ b/test/data/mock-data.js @@ -1049,6 +1049,31 @@ const NETWORKS_2_API_MOCK_RESULT = { }, }; +const MOCK_ADDRESS_BOOK = [ + { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: 'Contact 1', + }, + { + address: '0x43c9159B6251f3E205B9113A023C8256cDD40D91', + chainId: '0x1', + isEns: true, + memo: '', + name: 'example.eth', + }, +]; + +const MOCK_DOMAIN_RESOLUTION = { + addressBookEntryName: 'example.eth', + domainName: 'example.eth', + protocol: 'Ethereum Name Service', + resolvedAddress: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + resolvingSnap: 'Ethereum Name Service resolver', +}; + module.exports = { TOKENS_API_MOCK_RESULT, TOP_ASSETS_API_MOCK_RESULT, @@ -1059,4 +1084,6 @@ module.exports = { SWAP_TEST_ETH_DAI_TRADES_MOCK, SWAP_TEST_ETH_USDC_TRADES_MOCK, NETWORKS_2_API_MOCK_RESULT, + MOCK_ADDRESS_BOOK, + MOCK_DOMAIN_RESOLUTION, }; diff --git a/test/data/mock-send-state.json b/test/data/mock-send-state.json index 96cd95cfbd84..73468aca6171 100644 --- a/test/data/mock-send-state.json +++ b/test/data/mock-send-state.json @@ -63,6 +63,7 @@ "currentLocale": "en" }, "metamask": { + "accountsByChainId": {}, "ipfsGateway": "", "dismissSeedBackUpReminder": false, "usePhishDetect": true, @@ -131,7 +132,8 @@ "preferences": { "hideZeroBalanceTokens": false, "showFiatInTestnets": false, - "showTestNetworks": true + "showTestNetworks": true, + "tokenNetworkFilter": {} }, "seedPhraseBackedUp": null, "ensResolutionsByAddress": {}, diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 80e5499447d7..2932e5fc56d9 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -379,6 +379,7 @@ "showNativeTokenAsMainBalance": true, "showTestNetworks": true, "smartTransactionsOptInStatus": true, + "tokenNetworkFilter": {}, "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", diff --git a/test/e2e/constants.ts b/test/e2e/constants.ts index a1388b93a512..7123d3e4114a 100644 --- a/test/e2e/constants.ts +++ b/test/e2e/constants.ts @@ -51,6 +51,19 @@ export const DEFAULT_BTC_ACCOUNT = 'bc1qg6whd6pc0cguh6gpp3ewujm53hv32ta9hdp252'; /* Default (mocked) BTC balance used by the Bitcoin RPC provider */ export const DEFAULT_BTC_BALANCE = 1; // BTC +/* Default BTC fees rate */ +export const DEFAULT_BTC_FEES_RATE = 0.00001; // BTC + +/* Default BTC conversion rate to USD */ +export const DEFAULT_BTC_CONVERSION_RATE = 62000; // USD + +/* Default BTC transaction ID */ +export const DEFAULT_BTC_TRANSACTION_ID = + 'e4111a707317da67d49a71af4cbcf6c0546f900ca32c3842d2254e315d1fca18'; + +/* Number of sats in 1 BTC */ +export const SATS_IN_1_BTC = 100000000; // sats + /* Default (mocked) SOLANA address created using test SRP */ export const DEFAULT_SOLANA_ACCOUNT = 'E6Aa9DDv7zsePJHosoqiNb3cFuup3fkXTyRH2pZ1nVzP'; diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index fd2d5be42891..c2fba9d63424 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -225,6 +225,7 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { sortCallback: 'stringNumeric', }, shouldShowAggregatedBalancePopover: true, + tokenNetworkFilter: {}, }, selectedAddress: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', theme: 'light', diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index bea9e9bad77f..844c4766db3e 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -89,6 +89,7 @@ function onboardingFixture() { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, shouldShowAggregatedBalancePopover: true, }, useExternalServices: true, @@ -126,6 +127,7 @@ function onboardingFixture() { }, showTestNetworks: false, smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, }, QueuedRequestController: { queuedRequestCount: 0, @@ -664,6 +666,7 @@ class FixtureBuilder { return this.withPreferencesController({ preferences: { smartTransactionsOptInStatus: true, + tokenNetworkFilter: {}, }, }); } diff --git a/test/e2e/flask/btc/btc-send.spec.ts b/test/e2e/flask/btc/btc-send.spec.ts new file mode 100644 index 000000000000..71d58c5c41ea --- /dev/null +++ b/test/e2e/flask/btc/btc-send.spec.ts @@ -0,0 +1,163 @@ +import { strict as assert } from 'assert'; +import { Suite } from 'mocha'; +import { Driver } from '../../webdriver/driver'; +import { DEFAULT_BTC_ACCOUNT, DEFAULT_BTC_BALANCE } from '../../constants'; +import { + getTransactionRequest, + SendFlowPlaceHolders, + withBtcAccountSnap, +} from './common-btc'; + +export async function startSendFlow(driver: Driver, recipient?: string) { + // Wait a bit so the MultichainRatesController is able to fetch BTC -> USD rates. + await driver.delay(1000); + + // Start the send flow. + const sendButton = await driver.waitForSelector({ + text: 'Send', + tag: 'button', + css: '[data-testid="coin-overview-send"]', + }); + // FIXME: Firefox test is flaky without this delay. The send flow doesn't start properly. + if (driver.browser === 'firefox') { + await driver.delay(1000); + } + await sendButton.click(); + + // See the review button is disabled by default. + await driver.waitForSelector({ + text: 'Review', + tag: 'button', + css: '[disabled]', + }); + + if (recipient) { + // Set the recipient address (if any). + await driver.pasteIntoField( + `input[placeholder="${SendFlowPlaceHolders.RECIPIENT}"]`, + recipient, + ); + } +} + +describe('BTC Account - Send', function (this: Suite) { + it('can send complete the send flow', async function () { + await withBtcAccountSnap( + { title: this.test?.fullTitle() }, + async (driver, mockServer) => { + await startSendFlow(driver, DEFAULT_BTC_ACCOUNT); + + // TODO: Remove delay here. There is a race condition if the amount and address are set too fast. + await driver.delay(1000); + + // Set the amount to send. + const mockAmountToSend = '0.5'; + await driver.pasteIntoField( + `input[placeholder="${SendFlowPlaceHolders.AMOUNT}"]`, + mockAmountToSend, + ); + + // From here, the "summary panel" should have some information about the fees and total. + await driver.waitForSelector({ + text: 'Total', + tag: 'p', + }); + + // The review button will become available. + const snapReviewButton = await driver.findClickableElement({ + text: 'Review', + tag: 'button', + css: '.snap-ui-renderer__footer-button', + }); + assert.equal(await snapReviewButton.isEnabled(), true); + await snapReviewButton.click(); + + // TODO: There isn't any check for the fees and total amount. This requires calculating the vbytes used in a transaction dynamically. + // We already have unit tests for these calculations on the Snap. + + // ------------------------------------------------------------------------------ + // From here, we have moved to the confirmation screen (second part of the flow). + + // We should be able to send the transaction right away. + const snapSendButton = await driver.waitForSelector({ + text: 'Send', + tag: 'button', + css: '.snap-ui-renderer__footer-button', + }); + assert.equal(await snapSendButton.isEnabled(), true); + await snapSendButton.click(); + + // Check that we are selecting the "Activity tab" right after the send. + await driver.waitForSelector({ + tag: 'div', + text: 'Bitcoin activity is not supported', + }); + + const transaction = await getTransactionRequest(mockServer); + assert(transaction !== undefined); + }, + ); + }); + + it('can send the max amount', async function () { + await withBtcAccountSnap( + { title: this.test?.fullTitle() }, + async (driver, mockServer) => { + await startSendFlow(driver, DEFAULT_BTC_ACCOUNT); + + // TODO: Remove delay here. There is a race condition if the amount and address are set too fast. + await driver.delay(1000); + + // Use the max spendable amount of that account. + await driver.clickElement({ + text: 'Max', + tag: 'button', + }); + + // From here, the "summary panel" should have some information about the fees and total. + await driver.waitForSelector({ + text: 'Total', + tag: 'p', + }); + + await driver.waitForSelector({ + text: `${DEFAULT_BTC_BALANCE} BTC`, + tag: 'p', + }); + + // The review button will become available. + const snapReviewButton = await driver.findClickableElement({ + text: 'Review', + tag: 'button', + css: '.snap-ui-renderer__footer-button', + }); + assert.equal(await snapReviewButton.isEnabled(), true); + await snapReviewButton.click(); + + // TODO: There isn't any check for the fees and total amount. This requires calculating the vbytes used in a transaction dynamically. + // We already have unit tests for these calculations on the snap. + + // ------------------------------------------------------------------------------ + // From here, we have moved to the confirmation screen (second part of the flow). + + // We should be able to send the transaction right away. + const snapSendButton = await driver.waitForSelector({ + text: 'Send', + tag: 'button', + css: '.snap-ui-renderer__footer-button', + }); + assert.equal(await snapSendButton.isEnabled(), true); + await snapSendButton.click(); + + // Check that we are selecting the "Activity tab" right after the send. + await driver.waitForSelector({ + tag: 'div', + text: 'Bitcoin activity is not supported', + }); + + const transaction = await getTransactionRequest(mockServer); + assert(transaction !== undefined); + }, + ); + }); +}); diff --git a/test/e2e/flask/btc/common-btc.ts b/test/e2e/flask/btc/common-btc.ts index 6891b3bfd60e..452f9ad44f6b 100644 --- a/test/e2e/flask/btc/common-btc.ts +++ b/test/e2e/flask/btc/common-btc.ts @@ -1,11 +1,26 @@ import { Mockttp } from 'mockttp'; import FixtureBuilder from '../../fixture-builder'; import { withFixtures, unlockWallet } from '../../helpers'; -import { DEFAULT_BTC_ACCOUNT, DEFAULT_BTC_BALANCE } from '../../constants'; +import { + DEFAULT_BTC_ACCOUNT, + DEFAULT_BTC_BALANCE, + DEFAULT_BTC_FEES_RATE, + DEFAULT_BTC_TRANSACTION_ID, + DEFAULT_BTC_CONVERSION_RATE, + SATS_IN_1_BTC, +} from '../../constants'; import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; import { Driver } from '../../webdriver/driver'; import messages from '../../../../app/_locales/en/messages.json'; +const QUICKNODE_URL_REGEX = /^https:\/\/.*\.btc.*\.quiknode\.pro(\/|$)/u; + +export enum SendFlowPlaceHolders { + AMOUNT = 'Enter amount to send', + RECIPIENT = 'Enter receiving address', + LOADING = 'Preparing transaction', +} + export async function createBtcAccount(driver: Driver) { await driver.clickElement('[data-testid="account-menu-icon"]'); await driver.clickElement( @@ -27,12 +42,17 @@ export async function createBtcAccount(driver: Driver) { ); } +export function btcToSats(btc: number): number { + // Watchout, we're not using BigNumber(s) here (but that's ok for test purposes) + return btc * SATS_IN_1_BTC; +} + export async function mockBtcBalanceQuote( mockServer: Mockttp, address: string = DEFAULT_BTC_ACCOUNT, ) { return await mockServer - .forPost(/^https:\/\/.*\.btc.*\.quiknode\.pro(\/|$)/u) + .forPost(QUICKNODE_URL_REGEX) .withJsonBodyIncluding({ method: 'bb_getaddress', }) @@ -42,7 +62,7 @@ export async function mockBtcBalanceQuote( json: { result: { address, - balance: (DEFAULT_BTC_BALANCE * 1e8).toString(), // Converts from BTC to sats + balance: btcToSats(DEFAULT_BTC_BALANCE).toString(), // Converts from BTC to sats totalReceived: '0', totalSent: '0', unconfirmedBalance: '0', @@ -54,6 +74,105 @@ export async function mockBtcBalanceQuote( }); } +export async function mockBtcFeeCallQuote(mockServer: Mockttp) { + return await mockServer + .forPost(QUICKNODE_URL_REGEX) + .withJsonBodyIncluding({ + method: 'estimatesmartfee', + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + result: { + blocks: 1, + feerate: DEFAULT_BTC_FEES_RATE, // sats + }, + }, + }; + }); +} + +export async function mockMempoolInfo(mockServer: Mockttp) { + return await mockServer + .forPost(QUICKNODE_URL_REGEX) + .withJsonBodyIncluding({ + method: 'getmempoolinfo', + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + result: { + loaded: true, + size: 165194, + bytes: 93042828, + usage: 550175264, + total_fee: 1.60127931, + maxmempool: 2048000000, + mempoolminfee: DEFAULT_BTC_FEES_RATE, + minrelaytxfee: DEFAULT_BTC_FEES_RATE, + incrementalrelayfee: 0.00001, + unbroadcastcount: 0, + fullrbf: true, + }, + }, + }; + }); +} + +export async function mockGetUTXO(mockServer: Mockttp) { + return await mockServer + .forPost(QUICKNODE_URL_REGEX) + .withJsonBodyIncluding({ + method: 'bb_getutxos', + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + result: [ + { + txid: DEFAULT_BTC_TRANSACTION_ID, + vout: 0, + value: btcToSats(DEFAULT_BTC_BALANCE).toString(), + height: 101100110, + confirmations: 6, + }, + ], + }, + }; + }); +} + +export async function mockSendTransaction(mockServer: Mockttp) { + return await mockServer + .forPost(QUICKNODE_URL_REGEX) + .withJsonBodyIncluding({ + method: 'sendrawtransaction', + }) + .thenCallback(() => { + return { + statusCode: 200, + json: { + result: DEFAULT_BTC_TRANSACTION_ID, + }, + }; + }); +} + +export async function mockRatesCall(mockServer: Mockttp) { + return await mockServer + .forGet('https://min-api.cryptocompare.com/data/pricemulti') + .withQuery({ fsyms: 'btc', tsyms: 'usd,USD' }) + .thenCallback(() => { + return { + statusCode: 200, + json: { BTC: { USD: DEFAULT_BTC_CONVERSION_RATE } }, + }; + }); +} + export async function mockRampsDynamicFeatureFlag( mockServer: Mockttp, subDomain: string, @@ -87,7 +206,7 @@ export async function withBtcAccountSnap( title, bitcoinSupportEnabled, }: { title?: string; bitcoinSupportEnabled?: boolean }, - test: (driver: Driver) => Promise, + test: (driver: Driver, mockServer: Mockttp) => Promise, ) { await withFixtures( { @@ -99,17 +218,44 @@ export async function withBtcAccountSnap( title, dapp: true, testSpecificMock: async (mockServer: Mockttp) => [ + await mockRatesCall(mockServer), await mockBtcBalanceQuote(mockServer), // See: PROD_RAMP_API_BASE_URL await mockRampsDynamicFeatureFlag(mockServer, 'api'), // See: UAT_RAMP_API_BASE_URL await mockRampsDynamicFeatureFlag(mockServer, 'uat-api'), + await mockMempoolInfo(mockServer), + await mockBtcFeeCallQuote(mockServer), + await mockGetUTXO(mockServer), + await mockSendTransaction(mockServer), ], }, - async ({ driver }: { driver: Driver }) => { + async ({ driver, mockServer }: { driver: Driver; mockServer: Mockttp }) => { await unlockWallet(driver); await createBtcAccount(driver); - await test(driver); + await test(driver, mockServer); + }, + ); +} + +export async function getQuickNodeSeenRequests(mockServer: Mockttp) { + const seenRequests = await Promise.all( + ( + await mockServer.getMockedEndpoints() + ).map((mockedEndpoint) => mockedEndpoint.getSeenRequests()), + ); + return seenRequests + .flat() + .filter((request) => request.url.match(QUICKNODE_URL_REGEX)); +} + +export async function getTransactionRequest(mockServer: Mockttp) { + // Check that the transaction has been sent. + const transactionRequest = (await getQuickNodeSeenRequests(mockServer)).find( + async (request) => { + const body = (await request.body.getJson()) as { method: string }; + return body.method === 'sendrawtransaction'; }, ); + return transactionRequest; } diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index 1e03da918ed7..091f946a8071 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -1,4 +1,3 @@ -const { strict: assert } = require('assert'); const path = require('path'); const { promises: fs, writeFileSync, readFileSync } = require('fs'); const BigNumber = require('bignumber.js'); @@ -119,6 +118,9 @@ async function withFixtures(options, testSuite) { contractRegistry = ganacheSeeder.getContractRegistry(); } + await fixtureServer.start(); + fixtureServer.loadJsonState(fixtures, contractRegistry); + if (ganacheOptions?.concurrent) { ganacheOptions.concurrent.forEach(async (ganacheSettings) => { const { port, chainId, ganacheOptions2 } = ganacheSettings; @@ -138,8 +140,6 @@ async function withFixtures(options, testSuite) { await initBundler(bundlerServer, ganacheServer, usePaymaster); } - await fixtureServer.start(); - fixtureServer.loadJsonState(fixtures, contractRegistry); await phishingPageServer.start(); if (dapp) { if (dappOptions?.numberOfDapps) { @@ -385,243 +385,6 @@ const getWindowHandles = async (driver, handlesCount) => { return { extension, dapp, popup }; }; -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Begin the create new wallet flow on onboarding screen. - * @param {WebDriver} driver - */ -const onboardingBeginCreateNewWallet = async (driver) => { - // agree to terms of use - await driver.clickElement('[data-testid="onboarding-terms-checkbox"]'); - - // welcome - await driver.clickElement('[data-testid="onboarding-create-wallet"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Choose either "I Agree" or "No Thanks" on the MetaMetrics onboarding screen - * @param {WebDriver} driver - * @param {boolean} option - true to opt into metrics, default is false - */ -const onboardingChooseMetametricsOption = async (driver, option = false) => { - const optionIdentifier = option ? 'i-agree' : 'no-thanks'; - // metrics - await driver.clickElement(`[data-testid="metametrics-${optionIdentifier}"]`); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Set a password for MetaMask during onboarding - * @param {WebDriver} driver - * @param {string} password - Password to set - */ -const onboardingCreatePassword = async (driver, password) => { - // create password - await driver.fill('[data-testid="create-password-new"]', password); - await driver.fill('[data-testid="create-password-confirm"]', password); - await driver.clickElement('[data-testid="create-password-terms"]'); - await driver.clickElement('[data-testid="create-password-wallet"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Choose to secure wallet, and then get recovery phrase and confirm the SRP - * during onboarding flow. - * @param {WebDriver} driver - */ -const onboardingRevealAndConfirmSRP = async (driver) => { - // secure my wallet - await driver.clickElement('[data-testid="secure-wallet-recommended"]'); - - // reveal SRP - await driver.clickElement('[data-testid="recovery-phrase-reveal"]'); - - const revealedSeedPhrase = await driver.findElement( - '[data-testid="recovery-phrase-chips"]', - ); - - const recoveryPhrase = await revealedSeedPhrase.getText(); - - await driver.clickElement('[data-testid="recovery-phrase-next"]'); - - // confirm SRP - const words = recoveryPhrase.split(/\s*(?:[0-9)]+|\n|\.|^$|$)\s*/u); - const finalWords = words.filter((str) => str !== ''); - assert.equal(finalWords.length, 12); - - await driver.fill('[data-testid="recovery-phrase-input-2"]', finalWords[2]); - await driver.fill('[data-testid="recovery-phrase-input-3"]', finalWords[3]); - await driver.fill('[data-testid="recovery-phrase-input-7"]', finalWords[7]); - - await driver.clickElement('[data-testid="confirm-recovery-phrase"]'); - - await driver.clickElementAndWaitToDisappear({ - tag: 'button', - text: 'Confirm', - }); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Complete the onboarding flow by confirming completion. Final step before the - * reminder to pin the extension. - * @param {WebDriver} driver - */ -const onboardingCompleteWalletCreation = async (driver) => { - // complete - await driver.findElement({ text: 'Congratulations', tag: 'h2' }); - await driver.clickElement('[data-testid="onboarding-complete-done"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Move through the steps of pinning extension after successful onboarding - * @param {WebDriver} driver - */ -const onboardingPinExtension = async (driver) => { - // pin extension - await driver.clickElement('[data-testid="pin-extension-next"]'); - await driver.clickElement('[data-testid="pin-extension-done"]'); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Completes the onboarding flow with optional opt-out settings for wallet creation. - * - * This function navigates through the onboarding process, allowing for opt-out of certain features. - * It waits for the appropriate heading to appear, then proceeds to opt-out of third-party API - * integration for general and assets sections if specified in the optOutOptions. - * @param {WebDriver} driver - The Selenium WebDriver instance. - * @param {object} optOutOptions - Optional. An object specifying which features to opt-out of. - * @param {boolean} optOutOptions.basicFunctionality - Optional. Defaults to true. Opt-out of basic functionality. - * @param {boolean} optOutOptions.profileSync - Optional. Defaults to true. Opt-out of profile sync. - * @param {boolean} optOutOptions.assets - Optional. Defaults to true. Opt-out of assets options. - * @param {boolean} optOutOptions.isNewWallet - Optional. Defaults to true. Indicates if this is a new wallet creation. - */ -const onboardingCompleteWalletCreationWithOptOut = async ( - driver, - optOutOptions = {}, -) => { - const defaultOptOutOptions = { - basicFunctionality: true, - profileSync: true, - assets: true, - isNewWallet: true, - }; - - const optOutOptionsToUse = { ...defaultOptOutOptions, ...optOutOptions }; - - // wait for h2 to appear - await driver.findElement({ - text: optOutOptionsToUse.isNewWallet - ? 'Congratulations' - : 'Your wallet is ready', - tag: 'h2', - }); - - // opt-out from third party API on general section - await driver.clickElementAndWaitToDisappear({ - text: 'Manage default privacy settings', - tag: 'button', - }); - await driver.clickElement({ text: 'General', tag: 'p' }); - - if (optOutOptionsToUse.basicFunctionality) { - await driver.clickElement( - '[data-testid="basic-functionality-toggle"] .toggle-button', - ); - await driver.clickElement('[id="basic-configuration-checkbox"]'); - await driver.clickElementAndWaitToDisappear({ - tag: 'button', - text: 'Turn off', - }); - } - - if ( - optOutOptionsToUse.profileSync && - !optOutOptionsToUse.basicFunctionality - ) { - await driver.clickElement( - '[data-testid="profile-sync-toggle"] .toggle-button', - ); - await driver.clickElementAndWaitToDisappear({ - tag: 'button', - text: 'Turn off', - }); - } - - await driver.clickElement('[data-testid="category-back-button"]'); - - if (optOutOptionsToUse.assets) { - // opt-out from third party API on assets section - await driver.clickElement({ text: 'Assets', tag: 'p' }); - await Promise.all( - ( - await driver.findClickableElements( - '.toggle-button.toggle-button--on:not([data-testid="basic-functionality-toggle"] .toggle-button)', - ) - ).map((toggle) => toggle.click()), - ); - - await driver.clickElement('[data-testid="category-back-button"]'); - } - - // Wait until the onboarding carousel has stopped moving - // otherwise the click has no effect. - await driver.waitForElementToStopMoving( - '[data-testid="privacy-settings-back-button"]', - ); - await driver.clickElement('[data-testid="privacy-settings-back-button"]'); - - // complete onboarding - await driver.clickElementAndWaitToDisappear({ - tag: 'button', - text: 'Done', - }); - await onboardingPinExtension(driver); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * Completes the onboarding flow for creating a new wallet with opt-out options. - * - * This function guides the user through the onboarding process of creating a new wallet, - * including opting out of certain features as specified by the `optOutOptions` parameter. - * @param {object} driver - The Selenium driver instance. - * @param {string} password - The password to use for the new wallet. - * @param {object} optOutOptions - An object specifying the features to opt out of. - * @param {boolean} optOutOptions.isNewWallet - Indicates if this is a new wallet creation. - * @param {boolean} optOutOptions.basicFunctionality - Indicates if basic functionality should be opted out. - * @param {boolean} optOutOptions.profileSync - Indicates if profile sync should be opted out. - * @param {boolean} optOutOptions.assets - Indicates if assets should be opted out. - */ -const completeCreateNewWalletOnboardingFlowWithOptOut = async ( - driver, - password, - optOutOptions, -) => { - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); - await onboardingCreatePassword(driver, password); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreationWithOptOut(driver, optOutOptions); -}; - -/** - * @deprecated Please use page object functions in `onboarding.flow.ts` and in `pages/onboarding/*`. - * @param driver - * @param password - */ -const completeCreateNewWalletOnboardingFlow = async (driver, password) => { - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); - await onboardingCreatePassword(driver, password); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); -}; - const openSRPRevealQuiz = async (driver) => { // navigate settings to reveal SRP await driver.clickElement('[data-testid="account-options-menu-button"]'); @@ -636,11 +399,10 @@ const openSRPRevealQuiz = async (driver) => { await driver.clickElement('[data-testid="reveal-seed-words"]'); }; -const passwordUnlockOpenSRPRevealQuiz = async (driver) => { - await unlockWallet(driver); - await openSRPRevealQuiz(driver); -}; - +/** + * @deprecated Please use page object functions in `test/e2e/page-objects/pages/settings/privacy-settings.ts`. + * @param driver + */ const completeSRPRevealQuiz = async (driver) => { // start quiz await driver.clickElement('[data-testid="srp-quiz-get-started"]'); @@ -668,17 +430,6 @@ const tapAndHoldToRevealSRP = async (driver) => { ); }; -const closeSRPReveal = async (driver) => { - await driver.clickElement({ - text: tEn('close'), - tag: 'button', - }); - await driver.findVisibleElement({ - text: tEn('tokens'), - tag: 'button', - }); -}; - const DAPP_HOST_ADDRESS = '127.0.0.1:8080'; const DAPP_URL = `http://${DAPP_HOST_ADDRESS}`; const DAPP_ONE_URL = 'http://127.0.0.1:8081'; @@ -875,22 +626,6 @@ const sendTransaction = async ( } }; -const findAnotherAccountFromAccountList = async ( - driver, - itemNumber, - accountName, -) => { - await driver.clickElement('[data-testid="account-menu-icon"]'); - const accountMenuItemSelector = `.multichain-account-list-item:nth-child(${itemNumber})`; - - await driver.findElement({ - css: `${accountMenuItemSelector} .multichain-account-list-item__account-name__button`, - text: accountName, - }); - - return accountMenuItemSelector; -}; - const TEST_SEED_PHRASE = 'forum vessel pink push lonely enact gentle tail admit parrot grunt dress'; @@ -1235,12 +970,8 @@ module.exports = { largeDelayMs, veryLargeDelayMs, withFixtures, - completeCreateNewWalletOnboardingFlow, - completeCreateNewWalletOnboardingFlowWithOptOut, openSRPRevealQuiz, - passwordUnlockOpenSRPRevealQuiz, completeSRPRevealQuiz, - closeSRPReveal, tapAndHoldToRevealSRP, createDownloadFolder, openDapp, @@ -1254,7 +985,6 @@ module.exports = { multipleGanacheOptionsForType2Transactions, sendTransaction, sendScreenToConfirmScreen, - findAnotherAccountFromAccountList, unlockWallet, logInWithBalanceValidation, locateAccountBalanceDOM, @@ -1268,13 +998,6 @@ module.exports = { validateContractDetails, switchToNotificationWindow, getEventPayloads, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingCompleteWalletCreationWithOptOut, - onboardingPinExtension, assertInAnyOrder, genRandInitBal, openActionMenuAndStartSendFlow, diff --git a/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts b/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts index 1b6591899c0e..f4d8fdeb4e3d 100644 --- a/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts +++ b/test/e2e/helpers/user-storage/userStorageMockttpController.test.ts @@ -1,4 +1,5 @@ import * as mockttp from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { UserStorageMockttpController } from './userStorageMockttpController'; describe('UserStorageMockttpController', () => { @@ -12,11 +13,14 @@ describe('UserStorageMockttpController', () => { it('handles GET requests that have empty response', async () => { const controller = new UserStorageMockttpController(); - controller.setupPath('accounts', mockServer); + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(null); }); @@ -36,13 +40,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -62,13 +69,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(request.json).toEqual(mockedData); }); @@ -88,13 +98,16 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const request = await controller.onGet('accounts', { - path: `${baseUrl}/accounts/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, - }); + const request = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/7f8a7963423985c50f75f6ad42a6cf4e7eac43a6c55e3c6fcd49d73f01c1471b`, + }, + ); expect(request.json).toEqual(mockedData[0]); }); @@ -119,24 +132,30 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, - body: { - getJson: async () => ({ - data: mockedAddedData.Data, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/6afbe024087495b4e0d56c4bdfc981c84eba44a7c284d4f455b5db4fcabc2173`, + body: { + getJson: async () => ({ + data: mockedAddedData.Data, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual([...mockedData, mockedAddedData]); }); @@ -161,24 +180,30 @@ describe('UserStorageMockttpController', () => { Data: 'data3', }; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - body: { - getJson: async () => ({ - data: mockedUpdatedData.Data, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + body: { + getJson: async () => ({ + data: mockedUpdatedData.Data, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${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], mockedUpdatedData]); }); @@ -210,7 +235,7 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); @@ -219,20 +244,26 @@ describe('UserStorageMockttpController', () => { putData[entry.HashedKey] = entry.Data; }); - const putRequest = await controller.onPut('accounts', { - path: `${baseUrl}/accounts`, - body: { - getJson: async () => ({ - data: putData, - }), - } as unknown as mockttp.CompletedBody, - }); + const putRequest = await controller.onPut( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + body: { + getJson: async () => ({ + data: putData, + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(putRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${baseUrl}/accounts`, - }); + const getRequest = await controller.onGet( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}`, + }, + ); expect(getRequest.json).toEqual(mockedUpdatedData); }); @@ -252,19 +283,25 @@ describe('UserStorageMockttpController', () => { }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - path: `${baseUrl}/accounts/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, - }); + const deleteRequest = await controller.onDelete( + USER_STORAGE_FEATURE_NAMES.accounts, + { + path: `${baseUrl}/${USER_STORAGE_FEATURE_NAMES.accounts}/c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468`, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${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]]); }); @@ -282,22 +319,76 @@ describe('UserStorageMockttpController', () => { 'c236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', Data: 'data2', }, + { + HashedKey: + 'x236b92ea7d513b2beda062cb546986961dfa7ca4334a2913f7837e43d050468', + Data: 'data3', + }, ]; - controller.setupPath('accounts', mockServer, { + controller.setupPath(USER_STORAGE_FEATURE_NAMES.accounts, mockServer, { getResponse: mockedData, }); - const deleteRequest = await controller.onDelete('accounts', { - path: `${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], + }), + } as unknown as mockttp.CompletedBody, + }, + ); expect(deleteRequest.statusCode).toEqual(204); - const getRequest = await controller.onGet('accounts', { - path: `${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/test/e2e/helpers/user-storage/userStorageMockttpController.ts b/test/e2e/helpers/user-storage/userStorageMockttpController.ts index 970a10d11120..ce8583b9adcd 100644 --- a/test/e2e/helpers/user-storage/userStorageMockttpController.ts +++ b/test/e2e/helpers/user-storage/userStorageMockttpController.ts @@ -1,13 +1,22 @@ import { CompletedRequest, Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; + +const baseUrl = + 'https://user-storage\\.api\\.cx\\.metamask\\.io\\/api\\/v1\\/userstorage'; -// TODO: Export user storage schema from @metamask/profile-sync-controller 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', + ), }; type UserStorageResponseData = { HashedKey: string; Data: string }; @@ -70,50 +79,75 @@ export class UserStorageMockttpController { const isFeatureEntry = determineIfFeatureEntryFromURL(request.path); const data = (await request.body.getJson()) as { - data: string | { [key: string]: string }; + data?: string | Record; + batch_delete?: string[]; }; - const newOrUpdatedSingleOrBatchEntries = - isFeatureEntry && typeof data?.data === 'string' - ? [ - { - HashedKey: request.path.split('/').pop() as string, - 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 as { HashedKey: string; Data: string }, - ], - }); - } - }); + if (data?.data) { + const newOrUpdatedSingleOrBatchEntries = + isFeatureEntry && typeof data?.data === 'string' + ? [ + { + HashedKey: request.path.split('/').pop() as string, + 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 as { HashedKey: string; Data: string }, + ], + }); + } + }); + } return { statusCode, diff --git a/test/e2e/mock-balance-polling/mock-balance-polling.ts b/test/e2e/mock-balance-polling/mock-balance-polling.ts new file mode 100644 index 000000000000..ac0a38dbcdf9 --- /dev/null +++ b/test/e2e/mock-balance-polling/mock-balance-polling.ts @@ -0,0 +1,146 @@ +import { MockttpServer } from 'mockttp'; + +const CONTRACT_ADDRESS = { + BalanceCheckerEthereumMainnet: '0xb1f8e55c7f64d203c1400b9d8555d050f94adf39', + BalanceCheckerLineaMainnet: '0xf62e6a41561b3650a69bb03199c735e3e3328c0d', +}; + +const infuraUrl: string = + 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaMainnetUrl: string = + 'https://linea-mainnet.infura.io/v3/00000000000000000000000000000000'; +const infuraLineaSepoliaUrl: string = + 'https://linea-sepolia.infura.io/v3/00000000000000000000000000000000'; +const infuraSepoliaUrl: string = + 'https://sepolia.infura.io/v3/00000000000000000000000000000000'; + +/** + * Mocks multi network balance polling requests. + * + * @param mockServer - The mock server instance to set up the mocks on. + * @returns A promise that resolves when all mocks have been set up. + */ +export async function mockMultiNetworkBalancePolling( + mockServer: MockttpServer, +): Promise { + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraLineaMainnetUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraLineaSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1158E460913D00000', + }, + })); + + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ method: 'net_version' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: '0x1', + }, + })); + + await mockServer + .forPost(infuraLineaMainnetUrl) + .withJsonBodyIncluding({ method: 'eth_call' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '1111111111111111', + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000001158E460913D000000000000000000000000000000000000000000000000000000001699651aa88200000000000000000000000000000000000000000000000000001beca58919dc0000000000000000000000000000000000000000000000000000974189179054f0000000000000000000000000000000000000000000000000001d9ae54845818000000000000000000000000000000000000000000000000000009184e72a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110d9316ec0000000000000000000000000000000000000000000000000000000000000000000', + }, + })); + + await mockServer + .forPost(infuraUrl) + .withJsonBodyIncluding({ + method: 'eth_call', + params: [ + { + to: CONTRACT_ADDRESS.BalanceCheckerEthereumMainnet, + }, + ], + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6183194981233610', + result: + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000001158E460913D000000000000000000000000000000000000000000000000000000001699651aa88200000000000000000000000000000000000000000000000000001beca58919dc0000000000000000000000000000000000000000000000000000974189179054f0000000000000000000000000000000000000000000000000001d9ae54845818000000000000000000000000000000000000000000000000000009184e72a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000110d9316ec0000000000000000000000000000000000000000000000000000000000000000000', + }, + })); + + await mockServer + .forGet( + 'https://accounts.api.cx.metamask.io/v2/accounts/0x5cfe73b6021e818b776b421b1c4db2474086a7e1/balances', + ) + .withQuery({ + networks: 1, + }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 0, + balances: [ + { + object: 'token', + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + name: 'Ether', + type: 'native', + timestamp: '2015-07-30T03:26:13.000Z', + decimals: 18, + chainId: 1, + balance: '20', + }, + ], + unprocessedNetworks: [], + }, + })); +} diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index 5d372f3389a3..fc6b1ea4397a 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -43,6 +43,8 @@ const blacklistedHosts = [ 'goerli.infura.io', 'mainnet.infura.io', 'sepolia.infura.io', + 'linea-mainnet.infura.io', + 'linea-sepolia.infura.io', ]; const { mockEmptyStalelistAndHotlist, diff --git a/test/e2e/page-objects/flows/onboarding.flow.ts b/test/e2e/page-objects/flows/onboarding.flow.ts index e235048b0a01..01c5000162f3 100644 --- a/test/e2e/page-objects/flows/onboarding.flow.ts +++ b/test/e2e/page-objects/flows/onboarding.flow.ts @@ -5,21 +5,34 @@ import OnboardingSrpPage from '../pages/onboarding/onboarding-srp-page'; import StartOnboardingPage from '../pages/onboarding/start-onboarding-page'; import SecureWalletPage from '../pages/onboarding/secure-wallet-page'; import OnboardingCompletePage from '../pages/onboarding/onboarding-complete-page'; +import OnboardingPrivacySettingsPage from '../pages/onboarding/onboarding-privacy-settings-page'; import { WALLET_PASSWORD } from '../../helpers'; import { E2E_SRP } from '../../default-fixture'; /** * Create new wallet onboarding flow * - * @param driver - The WebDriver instance. - * @param password - The password to create. Defaults to WALLET_PASSWORD. + * @param options - The options object. + * @param options.driver - The WebDriver instance. + * @param [options.password] - The password to create. Defaults to WALLET_PASSWORD. + * @param [options.participateInMetaMetrics] - Whether to participate in MetaMetrics. Defaults to false. + * @param [options.needNavigateToNewPage] - Indicates whether to navigate to a new page before starting the onboarding flow. Defaults to true. */ -export const createNewWalletOnboardingFlow = async ( - driver: Driver, - password: string = WALLET_PASSWORD, -) => { +export const createNewWalletOnboardingFlow = async ({ + driver, + password = WALLET_PASSWORD, + participateInMetaMetrics = false, + needNavigateToNewPage = true, +}: { + driver: Driver; + password?: string; + participateInMetaMetrics?: boolean; + needNavigateToNewPage?: boolean; +}): Promise => { console.log('Starting the creation of a new wallet onboarding flow'); - await driver.navigate(); + if (needNavigateToNewPage) { + await driver.navigate(); + } const startOnboardingPage = new StartOnboardingPage(driver); await startOnboardingPage.check_pageIsLoaded(); await startOnboardingPage.checkTermsCheckbox(); @@ -27,7 +40,11 @@ export const createNewWalletOnboardingFlow = async ( const onboardingMetricsPage = new OnboardingMetricsPage(driver); await onboardingMetricsPage.check_pageIsLoaded(); - await onboardingMetricsPage.clickNoThanksButton(); + if (participateInMetaMetrics) { + await onboardingMetricsPage.clickIAgreeButton(); + } else { + await onboardingMetricsPage.clickNoThanksButton(); + } const onboardingPasswordPage = new OnboardingPasswordPage(driver); await onboardingPasswordPage.check_pageIsLoaded(); @@ -87,15 +104,30 @@ export const importSRPOnboardingFlow = async ({ /** * Complete create new wallet onboarding flow * - * @param driver - The WebDriver instance. - * @param password - The password to use. Defaults to WALLET_PASSWORD. + * @param options - The options object. + * @param options.driver - The WebDriver instance. + * @param [options.password] - The password to use. Defaults to WALLET_PASSWORD. + * @param [options.participateInMetaMetrics] - Whether to participate in MetaMetrics. Defaults to false. + * @param [options.needNavigateToNewPage] - Indicates whether to navigate to a new page before starting the onboarding flow. Defaults to true. */ -export const completeCreateNewWalletOnboardingFlow = async ( - driver: Driver, - password: string = WALLET_PASSWORD, -) => { +export const completeCreateNewWalletOnboardingFlow = async ({ + driver, + password = WALLET_PASSWORD, + participateInMetaMetrics = false, + needNavigateToNewPage = true, +}: { + driver: Driver; + password?: string; + participateInMetaMetrics?: boolean; + needNavigateToNewPage?: boolean; +}): Promise => { console.log('start to complete create new wallet onboarding flow '); - await createNewWalletOnboardingFlow(driver, password); + await createNewWalletOnboardingFlow({ + driver, + password, + participateInMetaMetrics, + needNavigateToNewPage, + }); const onboardingCompletePage = new OnboardingCompletePage(driver); await onboardingCompletePage.check_pageIsLoaded(); await onboardingCompletePage.check_congratulationsMessageIsDisplayed(); @@ -136,3 +168,50 @@ export const completeImportSRPOnboardingFlow = async ({ await onboardingCompletePage.check_walletReadyMessageIsDisplayed(); await onboardingCompletePage.completeOnboarding(); }; + +/** + * Complete create new wallet onboarding flow with custom privacy settings. + * + * @param options - The options object. + * @param options.driver - The WebDriver instance. + * @param options.password - The password to use. Defaults to WALLET_PASSWORD. + * @param options.needNavigateToNewPage - Whether to navigate to new page to start the onboarding flow. Defaults to true. + * @param options.toggleBasicFunctionality - Indicates if basic functionalities should be opted out. Defaults to true. + * @param options.toggleAssetsPrivacy - Indicates if assets privacy functionalities should be opted out. Defaults to true. + */ +export const completeCreateNewWalletOnboardingFlowWithCustomSettings = async ({ + driver, + password = WALLET_PASSWORD, + needNavigateToNewPage = true, + toggleBasicFunctionality = true, + toggleAssetsPrivacy = true, +}: { + driver: Driver; + password?: string; + needNavigateToNewPage?: boolean; + toggleBasicFunctionality?: boolean; + toggleAssetsPrivacy?: boolean; +}): Promise => { + await createNewWalletOnboardingFlow({ + driver, + password, + needNavigateToNewPage, + }); + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.navigateToDefaultPrivacySettings(); + + const onboardingPrivacySettingsPage = new OnboardingPrivacySettingsPage( + driver, + ); + if (toggleBasicFunctionality) { + await onboardingPrivacySettingsPage.toggleBasicFunctionalitySettings(); + } + if (toggleAssetsPrivacy) { + await onboardingPrivacySettingsPage.toggleAssetsSettings(); + } + + await onboardingPrivacySettingsPage.navigateBackToOnboardingCompletePage(); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.completeOnboarding(); +}; diff --git a/test/e2e/page-objects/pages/error-page.ts b/test/e2e/page-objects/pages/error-page.ts index 76acacdcf9dd..17b3f6714186 100644 --- a/test/e2e/page-objects/pages/error-page.ts +++ b/test/e2e/page-objects/pages/error-page.ts @@ -1,6 +1,6 @@ import { Driver } from '../../webdriver/driver'; import HeaderNavbar from './header-navbar'; -import SettingsPage from './settings-page'; +import SettingsPage from './settings/settings-page'; import DevelopOptionsPage from './developer-options-page'; const FEEDBACK_MESSAGE = diff --git a/test/e2e/page-objects/pages/homepage.ts b/test/e2e/page-objects/pages/homepage.ts index 101e6f9de83c..6a8a916d4349 100644 --- a/test/e2e/page-objects/pages/homepage.ts +++ b/test/e2e/page-objects/pages/homepage.ts @@ -1,6 +1,7 @@ import { strict as assert } from 'assert'; import { Driver } from '../../webdriver/driver'; import { Ganache } from '../../seeder/ganache'; +import { getCleanAppState } from '../../helpers'; import HeaderNavbar from './header-navbar'; class HomePage { @@ -11,10 +12,6 @@ class HomePage { private readonly activityTab = '[data-testid="account-overview__activity-tab"]'; - private readonly nftTab = '[data-testid="account-overview__nfts-tab"]'; - - private readonly nftIconOnActivityList = '[data-testid="nft-item"]'; - private readonly balance = '[data-testid="eth-overview__primary-currency"]'; private readonly basicFunctionalityOffWarningMessage = { @@ -48,6 +45,27 @@ class HomePage { private readonly transactionAmountsInActivity = '[data-testid="transaction-list-item-primary-currency"]'; + // NFT selectors + private readonly confirmImportNftButton = + '[data-testid="import-nfts-modal-import-button"]'; + + private readonly importNftAddressInput = '#address'; + + private readonly importNftButton = '[data-testid="import-nft-button"]'; + + private readonly importNftModalTitle = { text: 'Import NFT', tag: 'header' }; + + private readonly importNftTokenIdInput = '#token-id'; + + private readonly nftIconOnActivityList = '[data-testid="nft-item"]'; + + private readonly nftTab = '[data-testid="account-overview__nfts-tab"]'; + + private readonly successImportNftMessage = { + text: 'NFT was successfully added!', + tag: 'h6', + }; + constructor(driver: Driver) { this.driver = driver; this.headerNavbar = new HeaderNavbar(driver); @@ -84,8 +102,8 @@ class HomePage { await this.driver.clickElement(this.activityTab); } - async goToNFTList(): Promise { - console.log(`Open NFT tab on homepage`); + async goToNftTab(): Promise { + console.log(`Go to NFT tab on homepage`); await this.driver.clickElement(this.nftTab); } @@ -97,6 +115,35 @@ class HomePage { await this.driver.clickElement(this.sendButton); } + /** + * Imports an NFT by entering the NFT contract address and token ID + * + * @param nftContractAddress - The address of the NFT contract to import + * @param id - The ID of the NFT to import + * @param expectedErrorMessage - Expected error message if the import should fail + */ + async importNft( + nftContractAddress: string, + id: string, + expectedErrorMessage?: string, + ) { + await this.driver.clickElement(this.importNftButton); + await this.driver.waitForSelector(this.importNftModalTitle); + await this.driver.fill(this.importNftAddressInput, nftContractAddress); + await this.driver.fill(this.importNftTokenIdInput, id); + if (expectedErrorMessage) { + await this.driver.clickElement(this.confirmImportNftButton); + await this.driver.waitForSelector({ + tag: 'p', + text: expectedErrorMessage, + }); + } else { + await this.driver.clickElementAndWaitToDisappear( + this.confirmImportNftButton, + ); + } + } + /** * Checks if the toaster message for adding a network is displayed on the homepage. * @@ -167,6 +214,11 @@ class HomePage { ); } + async check_nftImageIsDisplayed(): Promise { + console.log('Check that NFT image is displayed in NFT tab on homepage'); + await this.driver.waitForSelector(this.nftIconOnActivityList); + } + /** * Checks if the toaster message for editing a network is displayed on the homepage. * @@ -244,6 +296,28 @@ class HomePage { await this.check_expectedBalanceIsDisplayed(expectedBalance); } + /** + * Checks if the NFT item with the specified name is displayed in the homepage nft tab. + * + * @param nftName - The name of the NFT to check for. + */ + async check_nftNameIsDisplayed(nftName: string): Promise { + console.log( + `Check that NFT item ${nftName} is displayed in NFT tab on homepage`, + ); + await this.driver.waitForSelector({ + tag: 'h5', + text: nftName, + }); + } + + async check_successImportNftMessageIsDisplayed(): Promise { + console.log( + 'Check that success imported NFT message is displayed on homepage', + ); + await this.driver.waitForSelector(this.successImportNftMessage); + } + /** * This function checks if a specified transaction amount at the specified index matches the expected one. * @@ -275,6 +349,17 @@ class HomePage { `Amount for transaction ${expectedNumber} is displayed as ${expectedAmount}`, ); } + + /** + * This function checks if account syncing has been successfully completed at least once. + */ + async check_hasAccountSyncingSyncedAtLeastOnce(): Promise { + console.log('Check if account syncing has synced at least once'); + await this.driver.wait(async () => { + const uiState = await getCleanAppState(this.driver); + return uiState.metamask.hasAccountSyncingSyncedAtLeastOnce === true; + }, 10000); + } } export default HomePage; diff --git a/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts b/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts index 2982acaa40c0..692bc547ba05 100644 --- a/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts +++ b/test/e2e/page-objects/pages/onboarding/onboarding-metrics-page.ts @@ -3,6 +3,8 @@ import { Driver } from '../../../webdriver/driver'; class OnboardingMetricsPage { private driver: Driver; + private readonly iAgreeButton = '[data-testid="metametrics-i-agree"]'; + private readonly metametricsMessage = { text: 'Help us improve MetaMask', tag: 'h2', @@ -33,6 +35,10 @@ class OnboardingMetricsPage { async clickNoThanksButton(): Promise { await this.driver.clickElementAndWaitToDisappear(this.noThanksButton); } + + async clickIAgreeButton(): Promise { + await this.driver.clickElementAndWaitToDisappear(this.iAgreeButton); + } } export default OnboardingMetricsPage; diff --git a/test/e2e/page-objects/pages/experimental-settings.ts b/test/e2e/page-objects/pages/settings/experimental-settings.ts similarity index 83% rename from test/e2e/page-objects/pages/experimental-settings.ts rename to test/e2e/page-objects/pages/settings/experimental-settings.ts index 7cd780229acd..3af5c8a538e4 100644 --- a/test/e2e/page-objects/pages/experimental-settings.ts +++ b/test/e2e/page-objects/pages/settings/experimental-settings.ts @@ -1,18 +1,18 @@ -import { Driver } from '../../webdriver/driver'; +import { Driver } from '../../../webdriver/driver'; class ExperimentalSettings { private readonly driver: Driver; // Locators - private readonly addAccountSnapToggle: string = + private readonly addAccountSnapToggle = '[data-testid="add-account-snap-toggle-div"]'; - private readonly experimentalPageTitle: object = { + private readonly experimentalPageTitle = { text: 'Experimental', tag: 'h4', }; - private readonly redesignedSignatureToggle: string = + private readonly redesignedSignatureToggle = '[data-testid="toggle-redesigned-confirmations-container"]'; constructor(driver: Driver) { diff --git a/test/e2e/page-objects/pages/settings/privacy-settings.ts b/test/e2e/page-objects/pages/settings/privacy-settings.ts new file mode 100644 index 000000000000..8fc0645f77e9 --- /dev/null +++ b/test/e2e/page-objects/pages/settings/privacy-settings.ts @@ -0,0 +1,217 @@ +import { Driver } from '../../../webdriver/driver'; +import { clickNestedButton } from '../../../helpers'; +import { tEn } from '../../../../lib/i18n-helpers'; + +class PrivacySettings { + private readonly driver: Driver; + + private readonly autodetectNftToggleButton = + '[data-testid="useNftDetection"] .toggle-button > div'; + + private readonly closeRevealSrpDialogButton = { + text: tEn('close'), + tag: 'button', + }; + + private readonly copiedSrpExclamation = { + text: tEn('copiedExclamation'), + tag: 'button', + }; + + private readonly copySrpButton = { + text: tEn('copyToClipboard'), + tag: 'button', + }; + + private readonly privacySettingsPageTitle = { + text: 'Security & privacy', + tag: 'h4', + }; + + // reveal SRP related locators + private readonly displayedSrpText = '[data-testid="srp_text"]'; + + private readonly holdToRevealSRPButton = { + text: tEn('holdToRevealSRP'), + tag: 'span', + }; + + private readonly revealSrpButton = '[data-testid="reveal-seed-words"]'; + + private readonly revealSrpNextButton = { + text: 'Next', + tag: 'button', + }; + + private readonly revealSrpPasswordInput = '[data-testid="input-password"]'; + + private readonly revealSrpQrCodeImage = '[data-testid="qr-srp"]'; + + private readonly revealSrpQuizContinueButton = + '[data-testid="srp-quiz-continue"]'; + + private readonly revealSrpQuizGetStartedButton = + '[data-testid="srp-quiz-get-started"]'; + + private readonly revealSrpQuizModalTitle = { + text: 'Security quiz', + tag: 'header', + }; + + private readonly revealSrpQuizQuestionOne = + '[data-testid="srp_stage_question_one"]'; + + private readonly revealSrpQuizQuestionTwo = + '[data-testid="srp_stage_question_two"]'; + + private readonly revealSrpQuizRightAnswerButton = + '[data-testid="srp-quiz-right-answer"]'; + + private readonly revealSrpQuizTryAgainButton = + '[data-testid="srp-quiz-try-again"]'; + + private readonly revealSrpQuizWrongAnswerButton = + '[data-testid="srp-quiz-wrong-answer"]'; + + private readonly revealSrpQuizWrongAnswerMessageOne = { + text: 'Wrong! No one can help get your Secret Recovery Phrase back', + tag: 'p', + }; + + private readonly revealSrpQuizWrongAnswerMessageTwo = { + text: 'Nope! Never share your Secret Recovery Phrase with anyone, ever', + tag: 'p', + }; + + private readonly revealSrpWrongPasswordMessage = '.mm-help-text'; + + constructor(driver: Driver) { + this.driver = driver; + } + + async check_pageIsLoaded(): Promise { + try { + await this.driver.waitForSelector(this.privacySettingsPageTitle); + } catch (e) { + console.log( + 'Timeout while waiting for Privacy & Security Settings page to be loaded', + e, + ); + throw e; + } + console.log('Privacy & Security Settings page is loaded'); + } + + async closeRevealSrpDialog(): Promise { + console.log('Close reveal SRP dialog on privacy settings page'); + await this.driver.clickElement(this.closeRevealSrpDialogButton); + } + + /** + * Complete reveal SRP quiz to open reveal SRP dialog. + * + * @param checkErrorAnswer - Whether to check for error answers during answering the quiz. Defaults to false. + */ + async completeRevealSrpQuiz( + checkErrorAnswer: boolean = false, + ): Promise { + console.log('Complete reveal SRP quiz on privacy settings page'); + await this.driver.clickElement(this.revealSrpQuizGetStartedButton); + + // answer quiz question 1 + if (checkErrorAnswer) { + await this.driver.waitForSelector(this.revealSrpQuizQuestionOne); + await this.driver.clickElement(this.revealSrpQuizWrongAnswerButton); + await this.driver.waitForSelector( + this.revealSrpQuizWrongAnswerMessageOne, + ); + await this.driver.clickElement(this.revealSrpQuizTryAgainButton); + } + await this.driver.waitForSelector(this.revealSrpQuizQuestionOne); + await this.driver.clickElement(this.revealSrpQuizRightAnswerButton); + await this.driver.clickElement(this.revealSrpQuizContinueButton); + + // answer quiz question 2 + if (checkErrorAnswer) { + await this.driver.waitForSelector(this.revealSrpQuizQuestionTwo); + await this.driver.clickElement(this.revealSrpQuizWrongAnswerButton); + await this.driver.waitForSelector( + this.revealSrpQuizWrongAnswerMessageTwo, + ); + await this.driver.clickElement(this.revealSrpQuizTryAgainButton); + } + await this.driver.waitForSelector(this.revealSrpQuizQuestionTwo); + await this.driver.clickElement(this.revealSrpQuizRightAnswerButton); + await this.driver.clickElement(this.revealSrpQuizContinueButton); + } + + /** + * Fill the password input and click the next button to reveal the SRP. + * + * @param password - The password to fill in the input. + * @param expectedErrorMessage - Whether to expect an error message. + */ + async fillPasswordToRevealSrp( + password: string, + expectedErrorMessage?: string, + ): Promise { + console.log('Fill password to reveal SRP on privacy settings page'); + await this.driver.fill(this.revealSrpPasswordInput, password); + await this.driver.clickElement(this.revealSrpNextButton); + if (expectedErrorMessage) { + await this.driver.waitForSelector({ + css: this.revealSrpWrongPasswordMessage, + text: expectedErrorMessage, + }); + } else { + await this.driver.holdMouseDownOnElement( + this.holdToRevealSRPButton, + 3000, + ); + } + } + + async getSrpInRevealSrpDialog(): Promise { + console.log('Get SRP in reveal SRP dialog on privacy settings page'); + await this.driver.waitForSelector(this.displayedSrpText); + return (await this.driver.findElement(this.displayedSrpText)).getText(); + } + + async openRevealSrpQuiz(): Promise { + console.log('Open reveal SRP quiz on privacy settings page'); + await this.driver.clickElement(this.revealSrpButton); + await this.driver.waitForSelector(this.revealSrpQuizModalTitle); + } + + async toggleAutodetectNft(): Promise { + console.log('Toggle autodetect NFT on privacy settings page'); + await this.driver.clickElement(this.autodetectNftToggleButton); + } + + async check_displayedSrpCanBeCopied(): Promise { + console.log('Check displayed SRP on privacy settings page can be copied'); + await this.driver.clickElement(this.copySrpButton); + await this.driver.waitForSelector(this.copiedSrpExclamation); + } + + async check_srpQrCodeIsDisplayed(): Promise { + console.log('Check SRP QR code is displayed on privacy settings page'); + await clickNestedButton(this.driver, 'QR'); + await this.driver.waitForSelector(this.revealSrpQrCodeImage); + } + + /** + * Check that the SRP text is displayed. + * + * @param expectedSrpText - The expected SRP text. + */ + async check_srpTextIsDisplayed(expectedSrpText: string): Promise { + console.log('Check SRP text is displayed on privacy settings page'); + await this.driver.waitForSelector({ + css: this.displayedSrpText, + text: expectedSrpText, + }); + } +} + +export default PrivacySettings; diff --git a/test/e2e/page-objects/pages/settings-page.ts b/test/e2e/page-objects/pages/settings/settings-page.ts similarity index 56% rename from test/e2e/page-objects/pages/settings-page.ts rename to test/e2e/page-objects/pages/settings/settings-page.ts index c029e34efc7e..d103aca83f97 100644 --- a/test/e2e/page-objects/pages/settings-page.ts +++ b/test/e2e/page-objects/pages/settings/settings-page.ts @@ -1,20 +1,27 @@ -import { Driver } from '../../webdriver/driver'; +import { Driver } from '../../../webdriver/driver'; class SettingsPage { private readonly driver: Driver; - // Locators - private readonly experimentalSettingsButton: object = { + private readonly closeSettingsPageButton = + '.settings-page__header__title-container__close-button'; + + private readonly developerOptionsButton = { + text: 'Developer Options', + css: '.tab-bar__tab__content__title', + }; + + private readonly experimentalSettingsButton = { text: 'Experimental', css: '.tab-bar__tab__content__title', }; - private readonly developerOptionsButton: object = { - text: 'Developer Options', + private readonly privacySettingsButton = { + text: 'Security & privacy', css: '.tab-bar__tab__content__title', }; - private readonly settingsPageTitle: object = { + private readonly settingsPageTitle = { text: 'Settings', css: 'h3', }; @@ -33,15 +40,25 @@ class SettingsPage { console.log('Settings page is loaded'); } - async goToExperimentalSettings(): Promise { - console.log('Navigating to Experimental Settings page'); - await this.driver.clickElement(this.experimentalSettingsButton); + async closeSettingsPage(): Promise { + console.log('Closing Settings page'); + await this.driver.clickElement(this.closeSettingsPageButton); } async goToDevelopOptionSettings(): Promise { console.log('Navigating to Develop options page'); await this.driver.clickElement(this.developerOptionsButton); } + + async goToExperimentalSettings(): Promise { + console.log('Navigating to Experimental Settings page'); + await this.driver.clickElement(this.experimentalSettingsButton); + } + + async goToPrivacySettings(): Promise { + console.log('Navigating to Privacy & Security Settings page'); + await this.driver.clickElement(this.privacySettingsButton); + } } export default SettingsPage; diff --git a/test/e2e/page-objects/pages/vault-decryptor-page.ts b/test/e2e/page-objects/pages/vault-decryptor-page.ts new file mode 100644 index 000000000000..0d04d231dcfe --- /dev/null +++ b/test/e2e/page-objects/pages/vault-decryptor-page.ts @@ -0,0 +1,96 @@ +import { Driver } from '../../webdriver/driver'; +import { WALLET_PASSWORD } from '../../helpers'; + +class VaultDecryptorPage { + private driver: Driver; + + private readonly decryptButton = { + text: 'Decrypt', + tag: 'button', + }; + + private readonly fileInput = '#fileinput'; + + private readonly passwordInput = '#passwordinput'; + + private readonly radioFileInput = '#radio-fileinput'; + + private readonly radioTextInput = '#radio-textinput'; + + private readonly textInput = '#textinput'; + + constructor(driver: Driver) { + this.driver = driver; + } + + async check_pageIsLoaded(): Promise { + try { + await this.driver.waitForMultipleSelectors([ + this.fileInput, + this.textInput, + ]); + } catch (e) { + console.log( + 'Timeout while waiting for Vault Decryptor page to be loaded', + e, + ); + throw e; + } + console.log('Vault Decryptor page is loaded'); + } + + /** + * Confirm the decryption process on the Vault Decryptor page. + */ + async confirmDecrypt() { + console.log('click to confirm decrypt on vault decryptor page'); + await this.driver.clickElement(this.decryptButton); + } + + /** + * Fill the password input field with the specified password. + * + * @param password - The password to fill in the password input field. Defaults to WALLET_PASSWORD. + */ + async fillPassword(password: string = WALLET_PASSWORD) { + await this.driver.fill(this.passwordInput, password); + } + + /** + * Fill the text input field with the specified vault text. + * + * @param vaultText - The text to fill in the text input field. + */ + async fillVaultText(vaultText: string) { + console.log('fill vault text on vault decryptor page'); + await this.driver.clickElement(this.radioTextInput); + await this.driver.fill(this.textInput, vaultText); + } + + /** + * Uploads a log file to the Vault Decryptor page. + * + * @param filePath - The path to the log file to upload. + */ + async uploadLogFile(filePath: string) { + console.log('upload log file on vault decryptor page'); + await this.driver.clickElement(this.radioFileInput); + const inputField = await this.driver.findElement(this.fileInput); + await inputField.sendKeys(filePath); + } + + /** + * Checks if the vault is decrypted and the seed phrase is correct. + * + * @param seedPhrase - The expected seed phrase. + */ + async check_vaultIsDecrypted(seedPhrase: string) { + console.log('check vault is decrypted on vault decryptor page'); + await this.driver.waitForSelector({ + text: seedPhrase, + tag: 'div', + }); + } +} + +export default VaultDecryptorPage; diff --git a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts index aed0e7377b16..1986de15dd88 100644 --- a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts +++ b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts @@ -1,4 +1,5 @@ import { type Locator, type Page } from '@playwright/test'; +import { Tenderly } from '../../swap/tenderly-network'; export class NetworkController { readonly page: Page; @@ -7,18 +8,12 @@ export class NetworkController { readonly addNetworkButton: Locator; - readonly addNetworkManuallyButton: Locator; - readonly approveBtn: Locator; readonly saveBtn: Locator; - readonly switchToNetworkBtn: Locator; - readonly gotItBtn: Locator; - readonly networkSearch: Locator; - readonly networkName: Locator; readonly networkRpc: Locator; @@ -27,43 +22,72 @@ export class NetworkController { readonly networkTicker: Locator; + readonly dismissBtn: Locator; + + readonly networkList: Locator; + + readonly networkListEdit: Locator; + + readonly rpcName: Locator; + + readonly addRpcDropDown: Locator; + + readonly addRpcURLBtn: Locator; + + readonly addURLBtn: Locator; + constructor(page: Page) { this.page = page; this.networkDisplay = this.page.getByTestId('network-display'); - this.addNetworkButton = this.page.getByText('Add network'); - this.addNetworkManuallyButton = this.page.getByTestId( - 'add-network-manually', + this.networkList = this.page.getByTestId( + 'network-list-item-options-button-0x1', ); + this.networkListEdit = this.page.getByTestId( + 'network-list-item-options-edit', + ); + this.addNetworkButton = this.page.getByText('Add a custom network'); + this.addRpcDropDown = this.page.getByTestId('test-add-rpc-drop-down'); + this.addRpcURLBtn = this.page.getByRole('button', { name: 'Add RPC URL' }); + this.addURLBtn = this.page.getByRole('button', { name: 'Add URL' }); this.saveBtn = this.page.getByRole('button', { name: 'Save' }); this.approveBtn = this.page.getByTestId('confirmation-submit-button'); - this.switchToNetworkBtn = this.page.locator('button', { - hasText: 'Switch to', - }); this.gotItBtn = this.page.getByRole('button', { name: 'Got it' }); - this.networkSearch = this.page.locator('input[type="search"]'); this.networkName = this.page.getByTestId('network-form-network-name'); - this.networkRpc = this.page.getByTestId('network-form-rpc-url'); + this.rpcName = this.page.getByTestId('rpc-name-input-test'); + this.networkRpc = this.page.getByTestId('rpc-url-input-test'); this.networkChainId = this.page.getByTestId('network-form-chain-id'); this.networkTicker = this.page.getByTestId('network-form-ticker-input'); + this.dismissBtn = this.page.getByRole('button', { name: 'Dismiss' }); } async addCustomNetwork(options: { name: string; + rpcName: string; url: string; chainID: string; symbol: string; }) { + let rpcName = options.name; await this.networkDisplay.click(); - await this.addNetworkButton.click(); - await this.addNetworkManuallyButton.click(); - - await this.networkName.waitFor(); - await this.networkName.fill(options.name); + if (options.name === Tenderly.Mainnet.name) { + rpcName = options.rpcName; + await this.networkList.click(); + await this.networkListEdit.click(); + } else { + await this.addNetworkButton.click(); + await this.networkName.fill(rpcName); + } + await this.addRpcDropDown.click(); + await this.addRpcURLBtn.click(); await this.networkRpc.fill(options.url); - await this.networkChainId.fill(options.chainID); + await this.rpcName.fill(rpcName); + await this.addURLBtn.click(); + if (options.name !== Tenderly.Mainnet.name) { + await this.networkChainId.fill(options.chainID); + } await this.networkTicker.fill(options.symbol); - await this.saveBtn.click(); - await this.switchToNetworkBtn.click(); + await this.saveBtn.waitFor({ state: 'visible' }); + await this.saveBtn.click({ timeout: 60000 }); } async addPopularNetwork(options: { networkName: string }) { @@ -72,13 +96,25 @@ export class NetworkController { const addBtn = this.page.getByTestId(`add-network-${options.networkName}`); await addBtn.click(); await this.approveBtn.click(); - await this.switchToNetworkBtn.click(); await this.gotItBtn.click(); } - async selectNetwork(options: { networkName: string }) { - await this.networkDisplay.click(); - await this.networkSearch.fill(options.networkName); - await this.page.getByText(options.networkName).click(); + async selectNetwork(options: { + name: string; + rpcName: string; + url: string; + chainID: string; + symbol: string; + }) { + const currentNetwork = await this.networkDisplay.textContent(); + if (currentNetwork !== options.name) { + await this.networkDisplay.click(); + if (options.name === Tenderly.Mainnet.name) { + await this.page.getByText(options.rpcName).click(); + await this.page.getByText(options.rpcName).click(); + } else { + await this.page.getByTestId(options.name).click(); + } + } } } diff --git a/test/e2e/playwright/shared/pageObjects/signup-page.ts b/test/e2e/playwright/shared/pageObjects/signup-page.ts index e4165fc1ab89..28909f23ba20 100644 --- a/test/e2e/playwright/shared/pageObjects/signup-page.ts +++ b/test/e2e/playwright/shared/pageObjects/signup-page.ts @@ -11,6 +11,10 @@ export class SignUpPage { readonly importWalletBtn: Locator; + readonly createWalletBtn: Locator; + + readonly metametricsBtn: Locator; + readonly confirmSecretBtn: Locator; readonly agreeBtn: Locator; @@ -21,6 +25,8 @@ export class SignUpPage { readonly passwordConfirmTxt: Locator; + readonly createPasswordBtn: Locator; + readonly agreeCheck: Locator; readonly agreeTandCCheck: Locator; @@ -35,30 +41,42 @@ export class SignUpPage { readonly nextBtn: Locator; - readonly enableButton: Locator; + readonly enableBtn: Locator; + + readonly secureWalletBtn: Locator; + + readonly skipBackupBtn: Locator; + + readonly skipSrpBackupBtn: Locator; constructor(page: Page) { this.page = page; this.getStartedBtn = page.locator('button:has-text("Get started")'); + this.createWalletBtn = page.getByTestId('onboarding-create-wallet'); this.importWalletBtn = page.locator( 'button:has-text("Import an existing wallet")', ); this.confirmSecretBtn = page.locator( 'button:has-text("Confirm Secret Recovery Phrase")', ); + this.metametricsBtn = page.getByTestId('metametrics-no-thanks'); this.agreeBtn = page.locator('button:has-text("I agree")'); + this.createPasswordBtn = page.getByTestId('create-password-wallet'); this.noThanksBtn = page.locator('button:has-text("No thanks")'); this.passwordTxt = page.getByTestId('create-password-new'); this.passwordConfirmTxt = page.getByTestId('create-password-confirm'); this.agreeCheck = page.getByTestId('create-new-vault__terms-checkbox'); this.agreeTandCCheck = page.getByTestId('onboarding-terms-checkbox'); this.agreePasswordTermsCheck = page.getByTestId('create-password-terms'); + this.secureWalletBtn = page.getByTestId('secure-wallet-later'); + this.skipBackupBtn = page.getByTestId('skip-srp-backup-popover-checkbox'); + this.skipSrpBackupBtn = page.getByTestId('skip-srp-backup'); this.importBtn = page.getByTestId('create-password-import'); this.doneBtn = page.getByTestId('pin-extension-done'); this.gotItBtn = page.getByTestId('onboarding-complete-done'); this.nextBtn = page.getByTestId('pin-extension-next'); this.agreeBtn = page.locator('button:has-text("I agree")'); - this.enableButton = page.locator('button:has-text("Enable")'); + this.enableBtn = page.locator('button:has-text("Enable")'); } async importWallet() { @@ -81,4 +99,20 @@ export class SignUpPage { await this.nextBtn.click(); await this.doneBtn.click(); } + + async createWallet() { + await this.agreeTandCCheck.click(); + await this.createWalletBtn.click(); + await this.metametricsBtn.click(); + await this.passwordTxt.fill(ACCOUNT_PASSWORD as string); + await this.passwordConfirmTxt.fill(ACCOUNT_PASSWORD as string); + await this.agreePasswordTermsCheck.click(); + await this.createPasswordBtn.click(); + await this.secureWalletBtn.click(); + await this.skipBackupBtn.click(); + await this.skipSrpBackupBtn.click(); + await this.gotItBtn.click(); + await this.nextBtn.click(); + await this.doneBtn.click(); + } } diff --git a/test/e2e/playwright/shared/pageObjects/wallet-page.ts b/test/e2e/playwright/shared/pageObjects/wallet-page.ts index 47a9b667c96c..4e31441451bf 100644 --- a/test/e2e/playwright/shared/pageObjects/wallet-page.ts +++ b/test/e2e/playwright/shared/pageObjects/wallet-page.ts @@ -13,15 +13,31 @@ export class WalletPage { readonly tokenTab: Locator; + readonly accountMenu: Locator; + + readonly addAccountButton: Locator; + + readonly importAccountButton: Locator; + + readonly importAccountConfirmBtn: Locator; + constructor(page: Page) { this.page = page; this.swapButton = this.page.getByTestId('token-overview-button-swap'); this.importTokensButton = this.page.getByText('Import tokens').first(); + this.accountMenu = this.page.getByTestId('account-menu-icon'); + this.importAccountButton = this.page.getByText('Import account'); this.importButton = this.page.getByText('Import ('); this.tokenTab = this.page.getByTestId('account-overview__asset-tab'); + this.addAccountButton = this.page.getByTestId( + 'multichain-account-menu-popover-action-button', + ); this.activityListTab = this.page.getByTestId( 'account-overview__activity-tab', ); + this.importAccountConfirmBtn = this.page.getByTestId( + 'import-account-confirm-button', + ); } async importTokens() { @@ -31,11 +47,21 @@ export class WalletPage { await this.importButton.click(); } + async importAccount(accountPK: string) { + await this.accountMenu.waitFor({ state: 'visible' }); + await this.accountMenu.click(); + await this.addAccountButton.click(); + await this.importAccountButton.click(); + await this.page.fill('#private-key-box', accountPK); + await this.importAccountConfirmBtn.click(); + } + async selectTokenWallet() { await this.tokenTab.click(); } async selectSwapAction() { + await this.swapButton.waitFor({ state: 'visible' }); await this.swapButton.click(); } diff --git a/test/e2e/playwright/swap/pageObjects/swap-page.ts b/test/e2e/playwright/swap/pageObjects/swap-page.ts index 094d92b0d116..c10536d37f05 100644 --- a/test/e2e/playwright/swap/pageObjects/swap-page.ts +++ b/test/e2e/playwright/swap/pageObjects/swap-page.ts @@ -3,6 +3,8 @@ import { type Locator, type Page } from '@playwright/test'; export class SwapPage { private page: Page; + private swapQty: string; + readonly toggleSmartSwap: Locator; readonly updateSettingsButton: Locator; @@ -27,8 +29,11 @@ export class SwapPage { readonly closeButton: Locator; + readonly viewInActivityBtn: Locator; + constructor(page: Page) { this.page = page; + this.swapQty = ''; this.toggleSmartSwap = this.page.locator('text="On"'); this.updateSettingsButton = this.page.getByTestId( 'update-transaction-settings-button', @@ -53,44 +58,80 @@ export class SwapPage { this.swapTokenButton = this.page.locator('button', { hasText: 'Swap' }); this.closeButton = this.page.getByText('Close'); this.backButton = this.page.locator('[title="Cancel"]'); + this.viewInActivityBtn = this.page.getByTestId( + 'page-container-footer-next', + ); } - async fetchQuote(options: { from?: string; to: string; qty: string }) { - // Enter Swap Quantity - await this.tokenQty.fill(options.qty); - + async enterQuote(options: { from?: string; to: string; qty: string }) { // Enter source token - if (options.from) { + const native = await this.page.$(`text=/${options.from}/`); + if (!native && options.from) { this.swapFromDropDown.click(); - await this.tokenSearch.fill(options.from); await this.selectTokenFromList(options.from); } - // Enter destionation token + const balanceString = await this.page + .locator('[class*="balance"]') + .first() + .textContent(); + if (balanceString) { + if (parseFloat(balanceString.split(' ')[1]) <= parseFloat(options.qty)) { + await this.goBack(); + // not enough balance so cancel out + return false; + } + } + + // Enter Swap Quantity + await this.tokenQty.fill(options.qty); + this.swapQty = options.qty; + + // Enter destination token await this.swapToDropDown.click(); - await this.tokenSearch.fill(options.to); await this.selectTokenFromList(options.to); + return true; + } + + async waitForQuote() { + let quoteFound = false; + do { + // Clear Swap Anyway button if present + const swapAnywayButton = await this.page.$('text=/Swap anyway/'); + if (swapAnywayButton) { + await swapAnywayButton.click(); + } + + // No quotes available + const noQuotes = await this.page.$('text=/No quotes available/'); + if (noQuotes) { + await this.goBack(); + break; + } + + if (await this.page.$('text=/New quotes in/')) { + quoteFound = true; + break; + } + await this.page.waitForTimeout(1000); + } while (!quoteFound); + + return quoteFound; } async swap() { await this.waitForCountDown(); - - // Clear Swap Anyway button if present - const swapAnywayButton = await this.page.$('text=/Swap anyway/'); - if (swapAnywayButton) { - await swapAnywayButton.click(); - } await this.swapTokenButton.click(); } - async switchTokens() { + async switchTokenOrder() { // Wait for swap button to appear await this.swapTokenButton.waitFor(); await this.switchTokensButton.click(); await this.waitForCountDown(); } - async gotBack() { + async goBack() { await this.backButton.click(); } @@ -98,9 +139,25 @@ export class SwapPage { await this.page.waitForSelector(`text=/New quotes in 0:${second}/`); } - async waitForTransactionToComplete() { - await this.page.waitForSelector('text=/Transaction complete/'); - await this.closeButton.click(); // Close button + async waitForTransactionToComplete(options: { seconds: number }) { + let countSecond = options.seconds; + let transactionCompleted; + do { + transactionCompleted = await this.page.$('text=/Transaction complete/'); + if (transactionCompleted) { + await this.closeButton.click(); + break; + } + + await this.page.waitForTimeout(1000); + countSecond -= 1; + } while (countSecond); + + if (!transactionCompleted && !countSecond) { + await this.viewInActivityBtn.click(); + return false; + } + return true; } async waitForInsufficentBalance() { @@ -109,20 +166,10 @@ export class SwapPage { } async selectTokenFromList(symbol: string) { - let searchItem; - do { - searchItem = await this.tokenList.first().textContent(); - } while (searchItem !== symbol); - - await this.tokenList.first().click(); - } - - async waitForSearchListToPopulate(symbol: string): Promise { - let searchItem; - do { - searchItem = await this.tokenList.first().textContent(); - } while (searchItem !== symbol); - - return await this.tokenList.first().click(); + await this.tokenSearch.waitFor(); + await this.tokenSearch.fill(symbol); + const regex = new RegExp(`^${symbol}$`, 'u'); + const searchItem = await this.tokenList.filter({ hasText: regex }); + await searchItem.click({ timeout: 5000 }); } } diff --git a/test/e2e/playwright/swap/specs/swap.spec.ts b/test/e2e/playwright/swap/specs/swap.spec.ts index a795f67b2753..73a85620eabf 100644 --- a/test/e2e/playwright/swap/specs/swap.spec.ts +++ b/test/e2e/playwright/swap/specs/swap.spec.ts @@ -1,4 +1,6 @@ +import { ethers } from 'ethers'; import { test } from '@playwright/test'; +import log from 'loglevel'; import { ChromeExtensionPage } from '../../shared/pageObjects/extension-page'; import { SignUpPage } from '../../shared/pageObjects/signup-page'; @@ -6,106 +8,108 @@ import { NetworkController } from '../../shared/pageObjects/network-controller-p import { SwapPage } from '../pageObjects/swap-page'; import { WalletPage } from '../../shared/pageObjects/wallet-page'; import { ActivityListPage } from '../../shared/pageObjects/activity-list-page'; +import { Tenderly, addFundsToAccount } from '../tenderly-network'; let swapPage: SwapPage; let networkController: NetworkController; let walletPage: WalletPage; let activityListPage: ActivityListPage; -const Tenderly = { - Mainnet: { - name: 'Tenderly', - url: 'https://rpc.tenderly.co/fork/cdbcd795-097d-4624-aa16-680374d89a43', - chainID: '1', - symbol: 'ETH', +const testSet = [ + { + quantity: '.5', + source: 'ETH', + type: 'native', + destination: 'DAI', + network: Tenderly.Mainnet, }, -}; + { + quantity: '50', + source: 'DAI', + type: 'unapproved', + destination: 'ETH', + network: Tenderly.Mainnet, + }, + + { + source: 'ETH', + quantity: '.5', + type: 'native', + destination: 'WETH', + network: Tenderly.Mainnet, + }, + { + quantity: '.3', + source: 'WETH', + type: 'wrapped', + destination: 'ETH', + network: Tenderly.Mainnet, + }, + { + quantity: '50', + source: 'DAI', + type: 'ERC20->ERC20', + destination: 'USDC', + network: Tenderly.Mainnet, + }, +]; -test.beforeEach( +test.beforeAll( 'Initialize extension, import wallet and add custom networks', async () => { const extension = new ChromeExtensionPage(); const page = await extension.initExtension(); + page.setDefaultTimeout(15000); + + const wallet = ethers.Wallet.createRandom(); + await addFundsToAccount(Tenderly.Mainnet.url, wallet.address); const signUp = new SignUpPage(page); - await signUp.importWallet(); + await signUp.createWallet(); networkController = new NetworkController(page); swapPage = new SwapPage(page); activityListPage = new ActivityListPage(page); + walletPage = new WalletPage(page); await networkController.addCustomNetwork(Tenderly.Mainnet); - walletPage = new WalletPage(page); - await page.waitForTimeout(2000); + await walletPage.importAccount(wallet.privateKey); }, ); -test('Swap ETH to DAI - Switch to Arbitrum and fetch quote - Switch ETH - WETH', async () => { - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'DAI', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to DAI', - }); - - await networkController.addPopularNetwork({ networkName: 'Arbitrum One' }); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ to: 'MATIC', qty: '.001' }); - await swapPage.waitForInsufficentBalance(); - await swapPage.gotBack(); +testSet.forEach((options) => { + test(`should swap ${options.type} token ${options.source} to ${options.destination} on ${options.network.name}'`, async () => { + await walletPage.selectTokenWallet(); + await networkController.selectNetwork(options.network); + await walletPage.selectSwapAction(); + const quoteEntered = await swapPage.enterQuote({ + from: options.source, + to: options.destination, + qty: options.quantity, + }); - await networkController.selectNetwork({ networkName: 'Tenderly' }); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to DAI', - }); - await walletPage.selectTokenWallet(); - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'WETH', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); -}); - -test('Swap WETH to ETH - Switch to Avalanche and fetch quote - Switch DAI - USDC', async () => { - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'WETH', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); - - await networkController.addPopularNetwork({ - networkName: 'Avalanche Network C-Chain', - }); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ to: 'USDC', qty: '.001' }); - await swapPage.waitForInsufficentBalance(); - - await swapPage.gotBack(); - - await networkController.selectNetwork({ networkName: 'Tenderly' }); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); - await walletPage.selectTokenWallet(); - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'DAI', to: 'USDC', qty: '.5' }); - await swapPage.switchTokens(); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap USDC to DAI', + if (quoteEntered) { + const quoteFound = await swapPage.waitForQuote(); + if (quoteFound) { + await swapPage.swap(); + const transactionCompleted = + await swapPage.waitForTransactionToComplete({ seconds: 60 }); + if (transactionCompleted) { + await walletPage.selectActivityList(); + await activityListPage.checkActivityIsConfirmed({ + activity: `Swap ${options.source} to ${options.destination}`, + }); + } else { + log.error(`\tERROR: Transaction did not complete. Skipping test`); + test.skip(); + } + } else { + log.error(`\tERROR: No quotes found on. Skipping test`); + test.skip(); + } + } else { + log.error(`\tERROR: Error while entering the quote. Skipping test`); + test.skip(); + } }); }); diff --git a/test/e2e/playwright/swap/tenderly-network.ts b/test/e2e/playwright/swap/tenderly-network.ts new file mode 100644 index 000000000000..996dee47a81a --- /dev/null +++ b/test/e2e/playwright/swap/tenderly-network.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import log from 'loglevel'; + +export const Tenderly = { + Mainnet: { + name: 'Ethereum Mainnet', + rpcName: 'Tenderly - Mainnet', + url: 'https://virtual.mainnet.rpc.tenderly.co/03bb8912-7505-4856-839f-52819a26d0cd', + chainID: '1', + symbol: 'ETH', + }, + Optimism: { + name: 'OP Mainnet', + rpcName: '', + url: 'https://virtual.optimism.rpc.tenderly.co/3170a58e-fa67-4ccc-9697-b13aff0f5c1a', + chainID: '10', + symbol: 'ETH', + }, + Polygon: { + name: 'Polygon', + rpcName: '', + url: 'https://virtual.polygon.rpc.tenderly.co/e834a81e-69ba-49e9-a6a5-be5b6eea3cdc', + chainID: '137', + symbol: 'ETH', + }, +}; + +export async function addFundsToAccount( + rpcURL: string, + account: string, + amount: string = '0xDE0B6B3A7640000', // 1 ETH by default +) { + const data = { + jsonrpc: '2.0', + method: 'tenderly_setBalance', + params: [[account], amount], + id: '1234', + }; + const response = await axios.post(rpcURL, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.error) { + log.error( + `\tERROR: RROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, + ); + } +} diff --git a/test/e2e/snaps/test-snap-cronjob.spec.js b/test/e2e/snaps/test-snap-cronjob.spec.js index 6f4e05883943..5fbc22d7ad71 100644 --- a/test/e2e/snaps/test-snap-cronjob.spec.js +++ b/test/e2e/snaps/test-snap-cronjob.spec.js @@ -3,6 +3,7 @@ const { withFixtures, unlockWallet, WINDOW_TITLES, + largeDelayMs, } = require('../helpers'); const FixtureBuilder = require('../fixture-builder'); const { TEST_SNAPS_WEBSITE_URL } = require('./enums'); @@ -74,7 +75,9 @@ describe('Test Snap Cronjob', function () { text: 'Reconnect to Cronjobs Snap', }); - // switch to dialog popup, wait for a maximum of 65 seconds + // Switching to dialog popup takes time, hence this wait is needed + await driver.delay(largeDelayMs); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); // look for the dialog popup to verify cronjob fired diff --git a/test/e2e/snaps/test-snap-metrics.spec.js b/test/e2e/snaps/test-snap-metrics.spec.js index 54ebd572d993..6c8ac7b9530f 100644 --- a/test/e2e/snaps/test-snap-metrics.spec.js +++ b/test/e2e/snaps/test-snap-metrics.spec.js @@ -900,6 +900,7 @@ describe('Test Snap Metrics', function () { testSpecificMock: mockSegment, ignoredConsoleErrors: [ 'MetaMask - RPC Error: Failed to fetch snap "npm:@metamask/bip32-example-snap": Failed to fetch tarball for package "@metamask/bip32-example-snap"..', + 'Failed to fetch snap "npm:@metamask/bip32-example-…ball for package "@metamask/bip32-example-snap"..', ], }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { diff --git a/test/e2e/tests/account/lockdown.spec.ts b/test/e2e/tests/account/lockdown.spec.ts index 4307e1e33d6e..c2c4706855e0 100644 --- a/test/e2e/tests/account/lockdown.spec.ts +++ b/test/e2e/tests/account/lockdown.spec.ts @@ -86,6 +86,7 @@ describe('lockdown', function (this: Mocha.Suite) { { fixtures: new FixtureBuilder().build(), ganacheOptions, + ignoredConsoleErrors: ['Error: Could not establish connection.'], title: this.test?.fullTitle(), }, async ({ driver }: { driver: Driver }) => { diff --git a/test/e2e/tests/account/snap-account-settings.spec.ts b/test/e2e/tests/account/snap-account-settings.spec.ts index 1a0c761fb4df..d96b76b540ec 100644 --- a/test/e2e/tests/account/snap-account-settings.spec.ts +++ b/test/e2e/tests/account/snap-account-settings.spec.ts @@ -2,11 +2,11 @@ import { Suite } from 'mocha'; import { withFixtures } from '../../helpers'; import FixtureBuilder from '../../fixture-builder'; import { Driver } from '../../webdriver/driver'; -import SettingsPage from '../../page-objects/pages/settings-page'; -import ExperimentalSettings from '../../page-objects/pages/experimental-settings'; -import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; -import HeaderNavbar from '../../page-objects/pages/header-navbar'; import AccountListPage from '../../page-objects/pages/account-list-page'; +import ExperimentalSettings from '../../page-objects/pages/settings/experimental-settings'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; describe('Add snap account experimental settings @no-mmi', function (this: Suite) { it('switch "Enable Add account snap" to on', async function () { diff --git a/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts b/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts index 7398747671c7..7e355302212a 100644 --- a/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts +++ b/test/e2e/tests/account/snap-account-signatures-and-disconnects.spec.ts @@ -2,9 +2,9 @@ import { Suite } from 'mocha'; import { Driver } from '../../webdriver/driver'; import { WINDOW_TITLES, withFixtures } from '../../helpers'; import FixtureBuilder from '../../fixture-builder'; -import ExperimentalSettings from '../../page-objects/pages/experimental-settings'; +import ExperimentalSettings from '../../page-objects/pages/settings/experimental-settings'; import HeaderNavbar from '../../page-objects/pages/header-navbar'; -import SettingsPage from '../../page-objects/pages/settings-page'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; import SnapSimpleKeyringPage from '../../page-objects/pages/snap-simple-keyring-page'; import TestDapp from '../../page-objects/pages/test-dapp'; import { installSnapSimpleKeyring } from '../../page-objects/flows/snap-simple-keyring.flow'; diff --git a/test/e2e/tests/account/snap-account-signatures.spec.ts b/test/e2e/tests/account/snap-account-signatures.spec.ts index 0c528b1dc20c..e04987561fc5 100644 --- a/test/e2e/tests/account/snap-account-signatures.spec.ts +++ b/test/e2e/tests/account/snap-account-signatures.spec.ts @@ -1,9 +1,12 @@ import { Suite } from 'mocha'; import { Driver } from '../../webdriver/driver'; import { WINDOW_TITLES, withFixtures } from '../../helpers'; -import ExperimentalSettings from '../../page-objects/pages/experimental-settings'; import FixtureBuilder from '../../fixture-builder'; +import ExperimentalSettings from '../../page-objects/pages/settings/experimental-settings'; import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; +import SnapSimpleKeyringPage from '../../page-objects/pages/snap-simple-keyring-page'; +import TestDapp from '../../page-objects/pages/test-dapp'; import { installSnapSimpleKeyring } from '../../page-objects/flows/snap-simple-keyring.flow'; import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; import { @@ -13,9 +16,6 @@ import { signTypedDataV4WithSnapAccount, signTypedDataWithSnapAccount, } from '../../page-objects/flows/sign.flow'; -import SettingsPage from '../../page-objects/pages/settings-page'; -import SnapSimpleKeyringPage from '../../page-objects/pages/snap-simple-keyring-page'; -import TestDapp from '../../page-objects/pages/test-dapp'; describe('Snap Account Signatures @no-mmi', function (this: Suite) { this.timeout(200000); // This test is very long, so we need an unusually high timeout diff --git a/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts b/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts index fc8a6d0ab240..328ad7811e1b 100644 --- a/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts +++ b/test/e2e/tests/confirmations/signatures/malicious-signatures.spec.ts @@ -10,6 +10,10 @@ import { withRedesignConfirmationFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; +import { + BlockaidReason, + BlockaidResultType, +} from '../../../../../shared/constants/security-provider'; import { assertSignatureRejectedMetrics, openDappAndTriggerSignature, @@ -80,6 +84,8 @@ describe('Malicious Confirmation Signature - Bad Domain @no-mmi', function (this alert_visualized: [], alert_visualized_count: 0, }, + securityAlertReason: BlockaidReason.notApplicable, + securityAlertResponse: BlockaidResultType.NotApplicable, }); }, mockSignatureRejected, @@ -130,6 +136,8 @@ describe('Malicious Confirmation Signature - Bad Domain @no-mmi', function (this alert_visualized: ['requestFrom'], alert_visualized_count: 1, }, + securityAlertReason: BlockaidReason.notApplicable, + securityAlertResponse: BlockaidResultType.NotApplicable, }); }, mockSignatureRejected, diff --git a/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts b/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts index 418cc4ab513d..8444deab7c61 100644 --- a/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts +++ b/test/e2e/tests/confirmations/signatures/personal-sign.spec.ts @@ -43,17 +43,18 @@ describe('Confirmation Signature - Personal Sign @no-mmi', function (this: Suite await copyAddressAndPasteWalletAddress(driver); await assertPastedAddress(driver); - await assertAccountDetailsMetrics( - driver, - mockedEndpoints as MockedEndpoint[], - 'personal_sign', - ); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await assertInfoValues(driver); await driver.clickElement('[data-testid="confirm-footer-button"]'); await assertVerifiedPersonalMessage(driver, publicAddress); + + await assertAccountDetailsMetrics( + driver, + mockedEndpoints as MockedEndpoint[], + 'personal_sign', + ); await assertSignatureConfirmedMetrics({ driver, mockedEndpoints: mockedEndpoints as MockedEndpoint[], diff --git a/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts b/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts index 6961f0a5eaf2..a7f2e7b81691 100644 --- a/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts +++ b/test/e2e/tests/confirmations/signatures/sign-typed-data-v3.spec.ts @@ -47,15 +47,17 @@ describe('Confirmation Signature - Sign Typed Data V3 @no-mmi', function (this: await copyAddressAndPasteWalletAddress(driver); await assertPastedAddress(driver); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + await driver.delay(1000); + + await assertInfoValues(driver); + await scrollAndConfirmAndAssertConfirm(driver); + await assertAccountDetailsMetrics( driver, mockedEndpoints as MockedEndpoint[], 'eth_signTypedData_v3', ); - await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); - - await assertInfoValues(driver); - await scrollAndConfirmAndAssertConfirm(driver); await assertSignatureConfirmedMetrics({ driver, mockedEndpoints: mockedEndpoints as MockedEndpoint[], diff --git a/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts b/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts index 1017d44a00dc..2f1c33fe4d07 100644 --- a/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts +++ b/test/e2e/tests/confirmations/signatures/sign-typed-data.spec.ts @@ -43,17 +43,16 @@ describe('Confirmation Signature - Sign Typed Data @no-mmi', function (this: Sui await copyAddressAndPasteWalletAddress(driver); await assertPastedAddress(driver); - await assertAccountDetailsMetrics( - driver, - mockedEndpoints as MockedEndpoint[], - 'eth_signTypedData', - ); - await assertInfoValues(driver); await driver.clickElement('[data-testid="confirm-footer-button"]'); await driver.delay(1000); + await assertAccountDetailsMetrics( + driver, + mockedEndpoints as MockedEndpoint[], + 'eth_signTypedData', + ); await assertSignatureConfirmedMetrics({ driver, mockedEndpoints: mockedEndpoints as MockedEndpoint[], diff --git a/test/e2e/tests/confirmations/signatures/signature-helpers.ts b/test/e2e/tests/confirmations/signatures/signature-helpers.ts index 5242be3f3c20..a4c5e9309249 100644 --- a/test/e2e/tests/confirmations/signatures/signature-helpers.ts +++ b/test/e2e/tests/confirmations/signatures/signature-helpers.ts @@ -8,6 +8,10 @@ import { unlockWallet, } from '../../../helpers'; import { Driver } from '../../../webdriver/driver'; +import { + BlockaidReason, + BlockaidResultType, +} from '../../../../../shared/constants/security-provider'; export const WALLET_ADDRESS = '0x5CfE73b6021E818B776b421B1c4Db2474086a7e1'; export const WALLET_ETH_BALANCE = '25'; @@ -31,6 +35,8 @@ type AssertSignatureMetricsOptions = { location?: string; expectedProps?: Record; withAnonEvents?: boolean; + securityAlertReason?: string; + securityAlertResponse?: string; }; type SignatureEventProperty = { @@ -40,7 +46,7 @@ type SignatureEventProperty = { environment_type: 'background'; locale: 'en'; security_alert_reason: string; - security_alert_response: 'NotApplicable'; + security_alert_response: string; signature_type: string; eip712_primary_type?: string; ui_customizations?: string[]; @@ -59,11 +65,15 @@ const signatureAnonProperties = { * @param signatureType * @param primaryType * @param uiCustomizations + * @param securityAlertReason + * @param securityAlertResponse */ function getSignatureEventProperty( signatureType: string, primaryType: string, uiCustomizations: string[], + securityAlertReason: string = BlockaidReason.checkingChain, + securityAlertResponse: string = BlockaidResultType.Loading, ): SignatureEventProperty { const signatureEventProperty: SignatureEventProperty = { account_type: 'MetaMask', @@ -72,8 +82,8 @@ function getSignatureEventProperty( chain_id: '0x539', environment_type: 'background', locale: 'en', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', + security_alert_reason: securityAlertReason, + security_alert_response: securityAlertResponse, ui_customizations: uiCustomizations, }; @@ -90,15 +100,15 @@ function assertSignatureRequestedMetrics( signatureEventProperty: SignatureEventProperty, withAnonEvents = false, ) { - assertEventPropertiesMatch(events, 'Signature Requested', { - ...signatureEventProperty, - security_alert_reason: 'NotApplicable', - }); + assertEventPropertiesMatch( + events, + 'Signature Requested', + signatureEventProperty, + ); if (withAnonEvents) { assertEventPropertiesMatch(events, 'Signature Requested Anon', { ...signatureEventProperty, - security_alert_reason: 'NotApplicable', ...signatureAnonProperties, }); } @@ -111,12 +121,16 @@ export async function assertSignatureConfirmedMetrics({ primaryType = '', uiCustomizations = ['redesigned_confirmation'], withAnonEvents = false, + securityAlertReason, + securityAlertResponse, }: AssertSignatureMetricsOptions) { const events = await getEventPayloads(driver, mockedEndpoints); const signatureEventProperty = getSignatureEventProperty( signatureType, primaryType, uiCustomizations, + securityAlertReason, + securityAlertResponse, ); assertSignatureRequestedMetrics( @@ -148,12 +162,16 @@ export async function assertSignatureRejectedMetrics({ location, expectedProps = {}, withAnonEvents = false, + securityAlertReason, + securityAlertResponse, }: AssertSignatureMetricsOptions) { const events = await getEventPayloads(driver, mockedEndpoints); const signatureEventProperty = getSignatureEventProperty( signatureType, primaryType, uiCustomizations, + securityAlertReason, + securityAlertResponse, ); assertSignatureRequestedMetrics( @@ -201,14 +219,44 @@ function assertEventPropertiesMatch( expectedProperties: object, ) { const event = events.find((e) => e.event === eventName); + + const actualProperties = { ...event.properties }; + const expectedProps = { ...expectedProperties }; + + compareSecurityAlertResponse(actualProperties, expectedProps, eventName); + assert(event, `${eventName} event not found`); assert.deepStrictEqual( - event.properties, - expectedProperties, + actualProperties, + expectedProps, `${eventName} event properties do not match`, ); } +function compareSecurityAlertResponse( + actualProperties: Record, + expectedProperties: Record, + eventName: string, +) { + if ( + expectedProperties.security_alert_response && + (expectedProperties.security_alert_response === 'loading' || + expectedProperties.security_alert_response === 'Benign') + ) { + if ( + actualProperties.security_alert_response !== 'loading' && + actualProperties.security_alert_response !== 'Benign' + ) { + assert.fail( + `${eventName} event properties do not match: security_alert_response is ${actualProperties.security_alert_response}`, + ); + } + // Remove the property from both objects to avoid comparison + delete actualProperties.security_alert_response; + delete expectedProperties.security_alert_response; + } +} + export async function clickHeaderInfoBtn(driver: Driver) { await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); diff --git a/test/e2e/tests/confirmations/signatures/siwe.spec.ts b/test/e2e/tests/confirmations/signatures/siwe.spec.ts index 2a7b43ba1099..9f261a28f569 100644 --- a/test/e2e/tests/confirmations/signatures/siwe.spec.ts +++ b/test/e2e/tests/confirmations/signatures/siwe.spec.ts @@ -11,6 +11,10 @@ import { withRedesignConfirmationFixtures, } from '../helpers'; import { TestSuiteArguments } from '../transactions/shared'; +import { + BlockaidReason, + BlockaidResultType, +} from '../../../../../shared/constants/security-provider'; import { assertAccountDetailsMetrics, assertHeaderInfoBalance, @@ -39,11 +43,6 @@ describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { await copyAddressAndPasteWalletAddress(driver); await assertPastedAddress(driver); - await assertAccountDetailsMetrics( - driver, - mockedEndpoints as MockedEndpoint[], - 'personal_sign', - ); await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); await assertInfoValues(driver); await scrollAndConfirmAndAssertConfirm(driver); @@ -52,6 +51,12 @@ describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { driver, '0xef8674a92d62a1876624547bdccaef6c67014ae821de18fa910fbff56577a65830f68848585b33d1f4b9ea1c3da1c1b11553b6aabe8446717daf7cd1e38a68271c', ); + + await assertAccountDetailsMetrics( + driver, + mockedEndpoints as MockedEndpoint[], + 'personal_sign', + ); await assertSignatureConfirmedMetrics({ driver, mockedEndpoints: mockedEndpoints as MockedEndpoint[], @@ -60,6 +65,8 @@ describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { 'redesigned_confirmation', 'sign_in_with_ethereum', ], + securityAlertReason: BlockaidReason.notApplicable, + securityAlertResponse: BlockaidResultType.NotApplicable, }); }, mockSignatureApproved, @@ -95,6 +102,8 @@ describe('Confirmation Signature - SIWE @no-mmi', function (this: Suite) { 'sign_in_with_ethereum', ], location: 'confirmation', + securityAlertReason: BlockaidReason.notApplicable, + securityAlertResponse: BlockaidResultType.NotApplicable, }); }, mockSignatureRejected, diff --git a/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts index baa3638330b6..4e340f5ef3ac 100644 --- a/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts @@ -118,6 +118,7 @@ async function mocks(server: MockttpServer) { export async function importTST(driver: Driver) { await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); await driver.clickElement('[data-testid="import-token-button"]'); + await driver.clickElement('[data-testid="importTokens"]'); await driver.waitForSelector({ css: '.import-tokens-modal__button-tab', diff --git a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts index 4f6093e349f2..b7902717a787 100644 --- a/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/nft-token-send-redesign.spec.ts @@ -207,7 +207,7 @@ async function createERC721WalletInitiatedTransactionAndAssertDetails( await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); const homePage = new HomePage(driver); - await homePage.goToNFTList(); + await homePage.goToNftTab(); await homePage.clickNFTIconOnActivityList(); const nftDetailsPage = new NFTDetailsPage(driver); @@ -296,7 +296,7 @@ async function createERC1155WalletInitiatedTransactionAndAssertDetails( await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); const homePage = new HomePage(driver); - await homePage.goToNFTList(); + await homePage.goToNftTab(); await homePage.clickNFTIconOnActivityList(); const nftDetailsPage = new NFTDetailsPage(driver); diff --git a/test/e2e/tests/metrics/app-installed.spec.js b/test/e2e/tests/metrics/app-installed.spec.ts similarity index 56% rename from test/e2e/tests/metrics/app-installed.spec.js rename to test/e2e/tests/metrics/app-installed.spec.ts index cb2ddde78198..91336e99ad32 100644 --- a/test/e2e/tests/metrics/app-installed.spec.js +++ b/test/e2e/tests/metrics/app-installed.spec.ts @@ -1,25 +1,21 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - getEventPayloads, - tinyDelayMs, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import OnboardingMetricsPage from '../../page-objects/pages/onboarding/onboarding-metrics-page'; +import StartOnboardingPage from '../../page-objects/pages/onboarding/start-onboarding-page'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'App Installed'. Do not use the constants from the metrics constants files, * because if these change we want a strong indicator to our data team that the * shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -44,15 +40,19 @@ describe('App Installed Events @no-mmi', function () { participateInMetaMetrics: true, }) .build(), - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { await driver.navigate(); - await driver.delay(tinyDelayMs); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); + const startOnboardingPage = new StartOnboardingPage(driver); + await startOnboardingPage.check_pageIsLoaded(); + await startOnboardingPage.checkTermsCheckbox(); + await startOnboardingPage.clickCreateWalletButton(); + + const onboardingMetricsPage = new OnboardingMetricsPage(driver); + await onboardingMetricsPage.check_pageIsLoaded(); + await onboardingMetricsPage.clickIAgreeButton(); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 1); @@ -74,14 +74,19 @@ describe('App Installed Events @no-mmi', function () { metaMetricsId: 'fake-metrics-id', }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { await driver.navigate(); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); + const startOnboardingPage = new StartOnboardingPage(driver); + await startOnboardingPage.check_pageIsLoaded(); + await startOnboardingPage.checkTermsCheckbox(); + await startOnboardingPage.clickCreateWalletButton(); + + const onboardingMetricsPage = new OnboardingMetricsPage(driver); + await onboardingMetricsPage.check_pageIsLoaded(); + await onboardingMetricsPage.clickNoThanksButton(); const mockedRequests = await getEventPayloads( driver, diff --git a/test/e2e/tests/metrics/developer-options-sentry.spec.ts b/test/e2e/tests/metrics/developer-options-sentry.spec.ts index 3c20f931f302..eaad1c4760e3 100644 --- a/test/e2e/tests/metrics/developer-options-sentry.spec.ts +++ b/test/e2e/tests/metrics/developer-options-sentry.spec.ts @@ -4,7 +4,7 @@ import { withFixtures, sentryRegEx } from '../../helpers'; import FixtureBuilder from '../../fixture-builder'; import { Driver } from '../../webdriver/driver'; import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; -import SettingsPage from '../../page-objects/pages/settings-page'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; import HeaderNavbar from '../../page-objects/pages/header-navbar'; import DevelopOptions from '../../page-objects/pages/developer-options-page'; import ErrorPage from '../../page-objects/pages/error-page'; diff --git a/test/e2e/tests/metrics/errors.spec.js b/test/e2e/tests/metrics/errors.spec.js index d4c27a87aba6..b35a38d83206 100644 --- a/test/e2e/tests/metrics/errors.spec.js +++ b/test/e2e/tests/metrics/errors.spec.js @@ -876,6 +876,7 @@ describe('Sentry errors', function () { }, quotesLastFetched: true, quotesLoadingStatus: true, + quotesRefreshCount: true, }, currentPopupId: false, // Initialized as undefined // Part of transaction controller store, but missing from the initial diff --git a/test/e2e/tests/metrics/nft-detection-metrics.spec.js b/test/e2e/tests/metrics/nft-detection-metrics.spec.ts similarity index 68% rename from test/e2e/tests/metrics/nft-detection-metrics.spec.js rename to test/e2e/tests/metrics/nft-detection-metrics.spec.ts index a0c901087425..1b3939162915 100644 --- a/test/e2e/tests/metrics/nft-detection-metrics.spec.js +++ b/test/e2e/tests/metrics/nft-detection-metrics.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -72,20 +63,14 @@ describe('Nft detection event @no-mmi', function () { useNftDetection: true, }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 3); assert.deepStrictEqual(events[0].properties, { diff --git a/test/e2e/tests/metrics/signature-approved.spec.js b/test/e2e/tests/metrics/signature-approved.spec.js index c6990820af8b..2ea84d281b50 100644 --- a/test/e2e/tests/metrics/signature-approved.spec.js +++ b/test/e2e/tests/metrics/signature-approved.spec.js @@ -1,4 +1,5 @@ const { strict: assert } = require('assert'); + const { defaultGanacheOptions, switchToNotificationWindow, @@ -47,6 +48,16 @@ async function mockSegment(mockServer) { ]; } +const expectedEventPropertiesBase = { + account_type: 'MetaMask', + category: 'inpage_provider', + locale: 'en', + chain_id: '0x539', + environment_type: 'background', + security_alert_reason: 'CheckingChain', + security_alert_response: 'loading', +}; + describe('Signature Approved Event @no-mmi', function () { it('Successfully tracked for signTypedData_v4', async function () { await withFixtures( @@ -76,31 +87,21 @@ describe('Signature Approved Event @no-mmi', function () { const events = await getEventPayloads(driver, mockedEndpoints); assert.deepStrictEqual(events[0].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData_v4', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', eip712_primary_type: 'Mail', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', }); assert.deepStrictEqual(events[1].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData_v4', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', eip712_primary_type: 'Mail', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', + security_alert_response: 'Benign', }); }, ); }); + it('Successfully tracked for signTypedData_v3', async function () { await withFixtures( { @@ -127,29 +128,21 @@ describe('Signature Approved Event @no-mmi', function () { await validateContractDetails(driver); await clickSignOnSignatureConfirmation({ driver }); const events = await getEventPayloads(driver, mockedEndpoints); + assert.deepStrictEqual(events[0].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData_v3', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', }); + assert.deepStrictEqual(events[1].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData_v3', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', + security_alert_response: 'Benign', }); }, ); }); + it('Successfully tracked for signTypedData', async function () { await withFixtures( { @@ -175,29 +168,21 @@ describe('Signature Approved Event @no-mmi', function () { await switchToNotificationWindow(driver); await clickSignOnSignatureConfirmation({ driver }); const events = await getEventPayloads(driver, mockedEndpoints); + assert.deepStrictEqual(events[0].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', }); + assert.deepStrictEqual(events[1].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'eth_signTypedData', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', + security_alert_response: 'Benign', }); }, ); }); + it('Successfully tracked for personalSign', async function () { await withFixtures( { @@ -223,25 +208,16 @@ describe('Signature Approved Event @no-mmi', function () { await switchToNotificationWindow(driver); await clickSignOnSignatureConfirmation({ driver }); const events = await getEventPayloads(driver, mockedEndpoints); + assert.deepStrictEqual(events[0].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'personal_sign', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', }); + assert.deepStrictEqual(events[1].properties, { - account_type: 'MetaMask', + ...expectedEventPropertiesBase, signature_type: 'personal_sign', - category: 'inpage_provider', - locale: 'en', - chain_id: '0x539', - environment_type: 'background', - security_alert_reason: 'NotApplicable', - security_alert_response: 'NotApplicable', + security_alert_response: 'Benign', }); }, ); diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index cebacab2b1d9..cea439180b5b 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -74,6 +74,7 @@ "srcTokenAddress": "0x0000000000000000000000000000000000000000" }, "quotes": {}, + "quotesRefreshCount": 0, "srcTokens": {}, "srcTopAssets": {} } @@ -144,8 +145,11 @@ "MultichainBalancesController": { "balances": "object" }, "MultichainRatesController": { "fiatCurrency": "usd", - "rates": { "btc": { "conversionDate": 0, "conversionRate": 0 } }, - "cryptocurrencies": ["btc"] + "rates": { + "btc": { "conversionDate": 0, "conversionRate": 0 }, + "sol": { "conversionDate": 0, "conversionRate": 0 } + }, + "cryptocurrencies": ["btc", "sol"] }, "NameController": { "names": "object", "nameSources": "object" }, "NetworkController": { @@ -233,6 +237,13 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", + "tokenNetworkFilter": { + "0x1": "boolean", + "0xaa36a7": "boolean", + "0xe705": "boolean", + "0xe708": "boolean", + "0x539": "boolean" + }, "shouldShowAggregatedBalancePopover": "boolean" }, "ipfsGateway": "string", @@ -310,9 +321,14 @@ "swapsFeatureFlags": {} } }, + "TokenBalancesController": { + "tokenBalances": "object" + }, "TokenListController": { "tokenList": "object", - "tokensChainsCache": {}, + "tokensChainsCache": { + "0x539": "object" + }, "preventPollingOnNetworkRestart": false }, "TokenRatesController": { "marketData": "object" }, @@ -334,6 +350,8 @@ "UserOperationController": { "userOperations": "object" }, "UserStorageController": { "isProfileSyncingEnabled": null, - "isProfileSyncingUpdateLoading": "boolean" + "isProfileSyncingUpdateLoading": "boolean", + "hasAccountSyncingSyncedAtLeastOnce": "boolean", + "isAccountSyncingReadyToBeDispatched": "boolean" } } diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 97399d34c508..3bc7057435c8 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -38,7 +38,13 @@ "redesignedConfirmationsEnabled": true, "redesignedTransactionsEnabled": "boolean", "tokenSortConfig": "object", - "showMultiRpcModal": "boolean", + "tokenNetworkFilter": { + "0x1": "boolean", + "0xaa36a7": "boolean", + "0xe705": "boolean", + "0xe708": "boolean", + "0x539": "boolean" + }, "shouldShowAggregatedBalancePopover": "boolean" }, "firstTimeFlowType": "import", @@ -174,7 +180,10 @@ "gasEstimateType": "none", "nonRPCGasFeeApisDisabled": "boolean", "tokenList": "object", - "tokensChainsCache": {}, + "tokensChainsCache": { + "0x539": "object" + }, + "tokenBalances": "object", "preventPollingOnNetworkRestart": false, "tokens": "object", "ignoredTokens": "object", @@ -198,8 +207,11 @@ "lastFetchedBlockNumbers": "object", "submitHistory": "object", "fiatCurrency": "usd", - "rates": { "btc": { "conversionDate": 0, "conversionRate": 0 } }, - "cryptocurrencies": ["btc"], + "rates": { + "btc": { "conversionDate": 0, "conversionRate": 0 }, + "sol": { "conversionDate": 0, "conversionRate": 0 } + }, + "cryptocurrencies": ["btc", "sol"], "snaps": "object", "jobs": "object", "database": null, @@ -214,6 +226,8 @@ "isSignedIn": "boolean", "isProfileSyncingEnabled": null, "isProfileSyncingUpdateLoading": "boolean", + "hasAccountSyncingSyncedAtLeastOnce": "boolean", + "isAccountSyncingReadyToBeDispatched": "boolean", "subscriptionAccountsSeen": "object", "isMetamaskNotificationsFeatureSeen": "boolean", "isNotificationServicesEnabled": "boolean", @@ -280,6 +294,7 @@ "srcTokenAddress": "0x0000000000000000000000000000000000000000" }, "quotes": {}, + "quotesRefreshCount": 0, "srcTokens": {}, "srcTopAssets": {} }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index 7622ad15937c..5f5f47f3e7ee 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -50,23 +50,6 @@ }, "snapsInstallPrivacyWarningShown": true }, - "BridgeController": { - "bridgeState": { - "bridgeFeatureFlags": { - "extensionSupport": "boolean", - "srcNetworkAllowlist": { - "0": "string", - "1": "string", - "2": "string" - }, - "destNetworkAllowlist": { - "0": "string", - "1": "string", - "2": "string" - } - } - } - }, "CurrencyController": { "currentCurrency": "usd", "currencyRates": { @@ -136,7 +119,8 @@ "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", "showMultiRpcModal": "boolean", - "shouldShowAggregatedBalancePopover": "boolean" + "shouldShowAggregatedBalancePopover": "boolean", + "tokenNetworkFilter": {} }, "selectedAddress": "string", "theme": "light", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index b3fa8d117beb..f997b89bcd28 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -135,6 +135,7 @@ "isRedesignedConfirmationsDeveloperEnabled": "boolean", "showConfirmationAdvancedDetails": false, "tokenSortConfig": "object", + "tokenNetworkFilter": {}, "showMultiRpcModal": "boolean", "shouldShowAggregatedBalancePopover": "boolean" }, diff --git a/test/e2e/tests/metrics/token-detection-metrics.spec.js b/test/e2e/tests/metrics/token-detection-metrics.spec.ts similarity index 68% rename from test/e2e/tests/metrics/token-detection-metrics.spec.js rename to test/e2e/tests/metrics/token-detection-metrics.spec.ts index 923f7c86a242..6c8672e81e5e 100644 --- a/test/e2e/tests/metrics/token-detection-metrics.spec.js +++ b/test/e2e/tests/metrics/token-detection-metrics.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -69,19 +60,14 @@ describe('Token detection event @no-mmi', function () { }) .withPreferencesController({ useTokenDetection: true }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 3); diff --git a/test/e2e/tests/metrics/wallet-created.spec.js b/test/e2e/tests/metrics/wallet-created.spec.ts similarity index 61% rename from test/e2e/tests/metrics/wallet-created.spec.js rename to test/e2e/tests/metrics/wallet-created.spec.ts index fbe80fb595dc..bcad42100442 100644 --- a/test/e2e/tests/metrics/wallet-created.spec.js +++ b/test/e2e/tests/metrics/wallet-created.spec.ts @@ -1,29 +1,20 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - WALLET_PASSWORD, - onboardingBeginCreateNewWallet, - onboardingChooseMetametricsOption, - onboardingCreatePassword, - onboardingRevealAndConfirmSRP, - onboardingCompleteWalletCreation, - onboardingPinExtension, - getEventPayloads, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); +import { strict as assert } from 'assert'; +import { Mockttp } from 'mockttp'; +import { getEventPayloads, withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { completeCreateNewWalletOnboardingFlow } from '../../page-objects/flows/onboarding.flow'; /** - * mocks the segment api multiple times for specific payloads that we expect to - * see when these tests are run. In this case we are looking for + * Mocks the segment API multiple times for specific payloads that we expect to + * see when these tests are run. In this case, we are looking for * 'Permissions Requested' and 'Permissions Received'. Do not use the constants * from the metrics constants files, because if these change we want a strong * indicator to our data team that the shape of data will change. * - * @param {import('mockttp').Mockttp} mockServer - * @returns {Promise[]} + * @param mockServer - The mock server instance. + * @returns */ -async function mockSegment(mockServer) { +async function mockSegment(mockServer: Mockttp) { return [ await mockServer .forPost('https://api.segment.io/v1/batch') @@ -58,20 +49,14 @@ describe('Wallet Created Events @no-mmi', function () { participateInMetaMetrics: true, }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, true); - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + participateInMetaMetrics: true, + }); const events = await getEventPayloads(driver, mockedEndpoints); assert.equal(events.length, 2); assert.deepStrictEqual(events[0].properties, { @@ -101,20 +86,13 @@ describe('Wallet Created Events @no-mmi', function () { metaMetricsId: 'fake-metrics-id', }) .build(), - defaultGanacheOptions, - title: this.test.fullTitle(), + title: this.test?.fullTitle(), testSpecificMock: mockSegment, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await driver.navigate(); - await onboardingBeginCreateNewWallet(driver); - await onboardingChooseMetametricsOption(driver, false); - - await onboardingCreatePassword(driver, WALLET_PASSWORD); - await onboardingRevealAndConfirmSRP(driver); - await onboardingCompleteWalletCreation(driver); - await onboardingPinExtension(driver); - + await completeCreateNewWalletOnboardingFlow({ + driver, + }); const mockedRequests = await getEventPayloads( driver, mockedEndpoints, diff --git a/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts b/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts index 7b9e2378b058..1940d4bf3fd6 100644 --- a/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/importing-private-key-account.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -28,9 +29,13 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -47,6 +52,7 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -75,7 +81,10 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -91,6 +100,7 @@ describe('Account syncing - Import With Private Key @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts index 992027dd7840..8e2908682542 100644 --- a/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/new-user-sync.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -28,7 +29,10 @@ describe('Account syncing - New User @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, @@ -38,13 +42,14 @@ describe('Account syncing - New User @no-mmi', function () { }, async ({ driver }) => { // Create a new wallet - await completeCreateNewWalletOnboardingFlow( + await completeCreateNewWalletOnboardingFlow({ driver, - NOTIFICATIONS_TEAM_PASSWORD, - ); + password: NOTIFICATIONS_TEAM_PASSWORD, + }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); // Open account menu and validate 1 account is shown const header = new HeaderNavbar(driver); @@ -77,7 +82,10 @@ describe('Account syncing - New User @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -94,6 +102,7 @@ describe('Account syncing - New User @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); // Open account menu and validate the 2 accounts have been retrieved const header = new HeaderNavbar(driver); diff --git a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts index f9574a27cb10..209d3a51fdaf 100644 --- a/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/onboarding-with-opt-out.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -35,9 +36,13 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -94,7 +99,10 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -102,10 +110,10 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { }, }, async ({ driver }) => { - await createNewWalletOnboardingFlow( + await createNewWalletOnboardingFlow({ driver, - NOTIFICATIONS_TEAM_PASSWORD, - ); + password: NOTIFICATIONS_TEAM_PASSWORD, + }); const onboardingCompletePage = new OnboardingCompletePage(driver); await onboardingCompletePage.check_pageIsLoaded(); await onboardingCompletePage.navigateToDefaultPrivacySettings(); @@ -146,7 +154,10 @@ describe('Account syncing - Opt-out Profile Sync @no-mmi', function () { title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { // Mocks are still set up to ensure that requests are not matched - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts index 31f92520f13e..23a5d1eaf47b 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-adding-account.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -46,6 +51,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -73,7 +79,10 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -89,6 +98,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -97,8 +107,9 @@ describe('Account syncing - Add Account @no-mmi', function () { const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - const accountSyncResponse = - userStorageMockttpController.paths.get('accounts')?.response; + const accountSyncResponse = userStorageMockttpController.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + )?.response; await accountListPage.check_numberOfAvailableAccounts( accountSyncResponse?.length as number, @@ -124,9 +135,13 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -143,6 +158,7 @@ describe('Account syncing - Add Account @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -168,7 +184,10 @@ describe('Account syncing - Add Account @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -192,8 +211,9 @@ describe('Account syncing - Add Account @no-mmi', function () { const accountListPage = new AccountListPage(driver); await accountListPage.check_pageIsLoaded(); - const accountSyncResponse = - userStorageMockttpController.paths.get('accounts')?.response; + const accountSyncResponse = userStorageMockttpController.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + )?.response; await accountListPage.check_numberOfAvailableAccounts( accountSyncResponse?.length as number, diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts index 45ee3ab23a85..22618d70f3c5 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-modifying-account-name.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, @@ -46,6 +51,7 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); @@ -72,7 +78,10 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -88,6 +97,7 @@ describe('Account syncing - Rename Accounts @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts b/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts index 5bebe7220e49..be2b2604633c 100644 --- a/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts +++ b/test/e2e/tests/notifications/account-syncing/sync-after-onboarding.spec.ts @@ -1,4 +1,5 @@ import { Mockttp } from 'mockttp'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { withFixtures } from '../../../helpers'; import FixtureBuilder from '../../../fixture-builder'; import { mockNotificationServices } from '../mocks'; @@ -27,9 +28,13 @@ describe('Account syncing - Onboarding @no-mmi', function () { fixtures: new FixtureBuilder({ onboarding: true }).build(), title: this.test?.fullTitle(), testSpecificMock: (server: Mockttp) => { - userStorageMockttpController.setupPath('accounts', server, { - getResponse: accountsSyncMockResponse, - }); + userStorageMockttpController.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + { + getResponse: accountsSyncMockResponse, + }, + ); return mockNotificationServices( server, userStorageMockttpController, @@ -45,6 +50,7 @@ describe('Account syncing - Onboarding @no-mmi', function () { const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); + await homePage.check_hasAccountSyncingSyncedAtLeastOnce(); const header = new HeaderNavbar(driver); await header.check_pageIsLoaded(); diff --git a/test/e2e/tests/notifications/mocks.ts b/test/e2e/tests/notifications/mocks.ts index ce2ced3df210..748084918272 100644 --- a/test/e2e/tests/notifications/mocks.ts +++ b/test/e2e/tests/notifications/mocks.ts @@ -4,6 +4,7 @@ import { NotificationServicesController, NotificationServicesPushController, } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { UserStorageMockttpController } from '../../helpers/user-storage/userStorageMockttpController'; const AuthMocks = AuthenticationController.Mocks; @@ -32,14 +33,35 @@ export async function mockNotificationServices( mockAPICall(server, AuthMocks.getMockAuthAccessTokenResponse()); // Storage - if (!userStorageMockttpControllerInstance?.paths.get('accounts')) { - userStorageMockttpControllerInstance.setupPath('accounts', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.accounts, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.accounts, + server, + ); } - if (!userStorageMockttpControllerInstance?.paths.get('networks')) { - userStorageMockttpControllerInstance.setupPath('networks', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.networks, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.networks, + server, + ); } - if (!userStorageMockttpControllerInstance?.paths.get('notifications')) { - userStorageMockttpControllerInstance.setupPath('notifications', server); + if ( + !userStorageMockttpControllerInstance?.paths.get( + USER_STORAGE_FEATURE_NAMES.notifications, + ) + ) { + userStorageMockttpControllerInstance.setupPath( + USER_STORAGE_FEATURE_NAMES.notifications, + server, + ); } // Notifications diff --git a/test/e2e/tests/onboarding/onboarding.spec.ts b/test/e2e/tests/onboarding/onboarding.spec.ts index 9ea81f040998..9409ef7e351c 100644 --- a/test/e2e/tests/onboarding/onboarding.spec.ts +++ b/test/e2e/tests/onboarding/onboarding.spec.ts @@ -41,7 +41,9 @@ describe('MetaMask onboarding @no-mmi', function () { title: this.test?.fullTitle(), }, async ({ driver }: { driver: Driver }) => { - await completeCreateNewWalletOnboardingFlow(driver); + await completeCreateNewWalletOnboardingFlow({ + driver, + }); const homePage = new HomePage(driver); await homePage.check_pageIsLoaded(); await homePage.check_expectedBalanceIsDisplayed(); diff --git a/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js b/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js index c1c7323671f5..9e904af6513e 100644 --- a/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js +++ b/test/e2e/tests/ppom/ppom-blockaid-alert-simple-send.spec.js @@ -8,6 +8,9 @@ const { logInWithBalanceValidation, WINDOW_TITLES, } = require('../../helpers'); +const { + mockMultiNetworkBalancePolling, +} = require('../../mock-balance-polling/mock-balance-polling'); const { SECURITY_ALERTS_PROD_API_BASE_URL } = require('./constants'); const { mockServerJsonRpc } = require('./mocks/mock-server-json-rpc'); @@ -32,13 +35,13 @@ const SEND_REQUEST_BASE_MOCK = { }; async function mockInfura(mockServer) { + await mockMultiNetworkBalancePolling(mockServer); await mockServerJsonRpc(mockServer, [ ['eth_blockNumber'], ['eth_call'], ['eth_estimateGas'], ['eth_feeHistory'], ['eth_gasPrice'], - ['eth_getBalance'], ['eth_getBlockByNumber'], ['eth_getCode'], ['eth_getTransactionCount'], diff --git a/test/e2e/tests/privacy/basic-functionality.spec.js b/test/e2e/tests/privacy/basic-functionality.spec.js index e6439c569339..77e9dad0b46f 100644 --- a/test/e2e/tests/privacy/basic-functionality.spec.js +++ b/test/e2e/tests/privacy/basic-functionality.spec.js @@ -102,7 +102,8 @@ describe('MetaMask onboarding @no-mmi', function () { // Wait until network is fully switched and refresh tokens before asserting to mitigate flakiness await driver.assertElementNotPresent('.loading-overlay'); - await driver.clickElement('[data-testid="refresh-list-button"]'); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement('[data-testid="refreshList"]'); for (let i = 0; i < mockedEndpoints.length; i += 1) { const requests = await mockedEndpoints[i].getSeenRequests(); @@ -157,7 +158,8 @@ describe('MetaMask onboarding @no-mmi', function () { // Wait until network is fully switched and refresh tokens before asserting to mitigate flakiness await driver.assertElementNotPresent('.loading-overlay'); - await driver.clickElement('[data-testid="refresh-list-button"]'); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement('[data-testid="refreshList"]'); // intended delay to allow for network requests to complete await driver.delay(1000); for (let i = 0; i < mockedEndpoints.length; i += 1) { diff --git a/test/e2e/tests/privacy/onboarding-infura-call-privacy.spec.ts b/test/e2e/tests/privacy/onboarding-infura-call-privacy.spec.ts index 1644cb068a3a..f1bdc08166b7 100644 --- a/test/e2e/tests/privacy/onboarding-infura-call-privacy.spec.ts +++ b/test/e2e/tests/privacy/onboarding-infura-call-privacy.spec.ts @@ -90,7 +90,7 @@ describe('MetaMask onboarding @no-mmi', function () { testSpecificMock: mockInfura, }, async ({ driver, mockedEndpoint: mockedEndpoints }) => { - await createNewWalletOnboardingFlow(driver); + await createNewWalletOnboardingFlow({ driver }); // Check no requests are made before completing creat new wallet onboarding // Intended delay to ensure we cover at least 1 polling loop of time for the network request diff --git a/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts new file mode 100644 index 000000000000..f565c0bb354e --- /dev/null +++ b/test/e2e/tests/privacy/onboarding-token-price-call-privacy.spec.ts @@ -0,0 +1,131 @@ +import assert from 'assert'; +import { Mockttp, MockedEndpoint } from 'mockttp'; +import { withFixtures, regularDelayMs } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import HomePage from '../../page-objects/pages/homepage'; +import OnboardingCompletePage from '../../page-objects/pages/onboarding/onboarding-complete-page'; +import { + importSRPOnboardingFlow, + createNewWalletOnboardingFlow, +} from '../../page-objects/flows/onboarding.flow'; + +// Mock function implementation for Token Price requests +async function mockTokenPriceApi( + mockServer: Mockttp, +): Promise { + return [ + // mainnet + await mockServer + .forGet('https://price.api.cx.metamask.io/v2/chains/1/spot-prices') + .thenCallback(() => ({ + statusCode: 200, + json: {}, + })), + ]; +} + +describe('MetaMask onboarding @no-mmi', function () { + it("doesn't make any token price API requests before create new wallet onboarding is completed", async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: mockTokenPriceApi, + }, + async ({ driver, mockedEndpoint: mockedEndpoints }) => { + await createNewWalletOnboardingFlow({ driver }); + + // Check no requests are made before completing creat new wallet onboarding + // Intended delay to ensure we cover at least 1 polling loop of time for the network request + await driver.delay(regularDelayMs); + for (const mockedEndpoint of mockedEndpoints) { + const isPending = await mockedEndpoint.isPending(); + assert.equal( + isPending, + true, + `${mockedEndpoint} mock should still be pending before onboarding`, + ); + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length, + 0, + `${mockedEndpoint} should make no requests before onboarding`, + ); + } + + // complete create new wallet onboarding + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.completeOnboarding(); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + + // network requests happen here + for (const mockedEndpoint of mockedEndpoints) { + await driver.wait(async () => { + const isPending = await mockedEndpoint.isPending(); + return isPending === false; + }, driver.timeout); + + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length > 0, + true, + `${mockedEndpoint} should make requests after onboarding`, + ); + } + }, + ); + }); + + it("doesn't make any token price API requests before onboarding by import is completed", async function () { + await withFixtures( + { + fixtures: new FixtureBuilder({ onboarding: true }) + .withNetworkControllerOnMainnet() + .build(), + title: this.test?.fullTitle(), + testSpecificMock: mockTokenPriceApi, + }, + async ({ driver, mockedEndpoint: mockedEndpoints }) => { + await importSRPOnboardingFlow({ driver }); + + // Check no requests before completing onboarding + // Intended delay to ensure we cover at least 1 polling loop of time for the network request + await driver.delay(regularDelayMs); + for (const mockedEndpoint of mockedEndpoints) { + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length, + 0, + `${mockedEndpoint} should make no requests before import wallet onboarding complete`, + ); + } + + // complete import wallet onboarding + const onboardingCompletePage = new OnboardingCompletePage(driver); + await onboardingCompletePage.check_pageIsLoaded(); + await onboardingCompletePage.completeOnboarding(); + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + + // requests happen here + for (const mockedEndpoint of mockedEndpoints) { + await driver.wait(async () => { + const isPending = await mockedEndpoint.isPending(); + return isPending === false; + }, driver.timeout); + + const requests = await mockedEndpoint.getSeenRequests(); + assert.equal( + requests.length > 0, + true, + `${mockedEndpoint} should make requests after onboarding`, + ); + } + }, + ); + }); +}); diff --git a/test/e2e/tests/settings/account-token-list.spec.js b/test/e2e/tests/settings/account-token-list.spec.js index 9e4822d0dbbc..ec24119dd44b 100644 --- a/test/e2e/tests/settings/account-token-list.spec.js +++ b/test/e2e/tests/settings/account-token-list.spec.js @@ -5,9 +5,38 @@ const { logInWithBalanceValidation, unlockWallet, } = require('../../helpers'); +const { + switchToNetworkFlow, +} = require('../../page-objects/flows/network.flow'); +const { mockServerJsonRpc } = require('../ppom/mocks/mock-server-json-rpc'); const FixtureBuilder = require('../../fixture-builder'); +const infuraSepoliaUrl = + 'https://sepolia.infura.io/v3/00000000000000000000000000000000'; + +async function mockInfura(mockServer) { + await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], + ]); + await mockServer + .forPost(infuraSepoliaUrl) + .withJsonBodyIncluding({ method: 'eth_getBalance' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + jsonrpc: '2.0', + id: '6857940763865360', + result: '0x15af1d78b58c40000', + }, + })); +} + +async function mockInfuraResponses(mockServer) { + await mockInfura(mockServer); +} + describe('Settings', function () { it('Should match the value of token list item and account list item for eth conversion', async function () { await withFixtures( @@ -49,6 +78,7 @@ describe('Settings', function () { .build(), ganacheOptions: defaultGanacheOptions, title: this.test.fullTitle(), + testSpecificMock: mockInfuraResponses, }, async ({ driver }) => { await unlockWallet(driver); @@ -63,6 +93,20 @@ describe('Settings', function () { ); await driver.delay(1000); assert.equal(await tokenListAmount.getText(), '$42,500.00\nUSD'); + + // switch to Sepolia + // the account list item used to always show account.balance as long as its EVM network. + // Now we are showing aggregated fiat balance on non testnetworks; but if it is a testnetwork we will show account.balance. + // The current test was running initially on localhost + // which is not a testnetwork resulting in the code trying to calculate the aggregated total fiat balance which shows 0.00$ + // If this test switches to mainnet then switches back to localhost; the test will pass because switching to mainnet + // will make the code calculate the aggregate fiat balance on mainnet+Linea mainnet and because this account in this test + // has 42,500.00 native Eth on mainnet then the aggregated total fiat would be 42,500.00. When the user switches back to localhost + // it will show the total that the test is expecting. + + // I think we can slightly modify this test to switch to Sepolia network before checking the account List item value + await switchToNetworkFlow(driver, 'Sepolia'); + await driver.clickElement('[data-testid="account-menu-icon"]'); const accountTokenValue = await driver.waitForSelector( '.multichain-account-list-item .multichain-account-list-item__asset', @@ -72,4 +116,41 @@ describe('Settings', function () { }, ); }); + + it('Should show crypto value when price checker setting is off', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder() + .withConversionRateEnabled() + .withShowFiatTestnetEnabled() + .withPreferencesControllerShowNativeTokenAsMainBalanceDisabled() + .withConversionRateDisabled() + .build(), + ganacheOptions: defaultGanacheOptions, + title: this.test.fullTitle(), + testSpecificMock: mockInfuraResponses, + }, + async ({ driver }) => { + await unlockWallet(driver); + + await driver.clickElement('[data-testid="popover-close"]'); + await driver.clickElement( + '[data-testid="account-overview__asset-tab"]', + ); + + const tokenListAmount = await driver.findElement( + '.eth-overview__primary-container', + ); + await driver.delay(1000); + assert.equal(await tokenListAmount.getText(), '25\nETH'); + + await driver.clickElement('[data-testid="account-menu-icon"]'); + const accountTokenValue = await driver.waitForSelector( + '.multichain-account-list-item .multichain-account-list-item__asset', + ); + + assert.equal(await accountTokenValue.getText(), '25ETH'); + }, + ); + }); }); diff --git a/test/e2e/tests/settings/settings-security-reveal-srp.spec.js b/test/e2e/tests/settings/settings-security-reveal-srp.spec.js deleted file mode 100644 index 7f68f53f8dc7..000000000000 --- a/test/e2e/tests/settings/settings-security-reveal-srp.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -const { strict: assert } = require('assert'); -const { - withFixtures, - passwordUnlockOpenSRPRevealQuiz, - completeSRPRevealQuiz, - tapAndHoldToRevealSRP, - closeSRPReveal, - clickNestedButton, -} = require('../../helpers'); -const FixtureBuilder = require('../../fixture-builder'); -const { tEn } = require('../../../lib/i18n-helpers'); -const { E2E_SRP } = require('../../default-fixture'); - -describe('Reveal SRP through settings', function () { - const testPassword = 'correct horse battery staple'; - const wrongTestPassword = 'test test test test'; - - it('should not reveal SRP text with incorrect password', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await passwordUnlockOpenSRPRevealQuiz(driver); - await completeSRPRevealQuiz(driver); - await driver.fill('#password-box', wrongTestPassword); - await driver.press('#password-box', driver.Key.ENTER); - const passwordErrorIsDisplayed = await driver.isElementPresent({ - css: '.mm-help-text', - text: 'Incorrect password', - }); - assert.equal(passwordErrorIsDisplayed, true); - }, - ); - }); - - it('completes quiz and reveals SRP text', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await passwordUnlockOpenSRPRevealQuiz(driver); - await completeSRPRevealQuiz(driver); - - // enter password - await driver.fill('#password-box', testPassword); - await driver.press('#password-box', driver.Key.ENTER); - - await tapAndHoldToRevealSRP(driver); - - // confirm SRP text matches expected - await driver.waitForSelector({ - css: '[data-testid="srp_text"]', - text: E2E_SRP, - }); - - // copy SRP text to clipboard - await driver.clickElement({ - text: tEn('copyToClipboard'), - tag: 'button', - }); - await driver.findVisibleElement({ - text: tEn('copiedExclamation'), - tag: 'button', - }); - - // confirm that CTA returns user to wallet view - await closeSRPReveal(driver); - }, - ); - }); - - it('completes quiz and reveals SRP QR after wrong answers', async function () { - await withFixtures( - { - fixtures: new FixtureBuilder().build(), - title: this.test.fullTitle(), - }, - async ({ driver }) => { - await passwordUnlockOpenSRPRevealQuiz(driver); - - // start quiz - await driver.clickElement('[data-testid="srp-quiz-get-started"]'); - - // tap incorrect answer 1 - await driver.clickElement('[data-testid="srp-quiz-wrong-answer"]'); - - // try again - await driver.clickElement('[data-testid="srp-quiz-try-again"]'); - - // tap correct answer 1 - await driver.clickElement('[data-testid="srp-quiz-right-answer"]'); - - // tap Continue 1 - await driver.clickElement('[data-testid="srp-quiz-continue"]'); - - // tap incorrect answer 2 - await driver.clickElement('[data-testid="srp-quiz-wrong-answer"]'); - - // try again - await driver.clickElement('[data-testid="srp-quiz-try-again"]'); - - // tap correct answer 1 - await driver.clickElement('[data-testid="srp-quiz-right-answer"]'); - - // tap Continue 2 - await driver.clickElement('[data-testid="srp-quiz-continue"]'); - - // enter password - await driver.fill('#password-box', testPassword); - await driver.press('#password-box', driver.Key.ENTER); - - // tap and hold to reveal - await tapAndHoldToRevealSRP(driver); - - // confirm SRP QR is displayed - await clickNestedButton(driver, 'QR'); - const qrCode = await driver.findElement('[data-testid="qr-srp"]'); - assert.equal(await qrCode.isDisplayed(), true); - - // confirm that CTA returns user to wallet view - await closeSRPReveal(driver); - }, - ); - }); -}); diff --git a/test/e2e/tests/settings/settings-security-reveal-srp.spec.ts b/test/e2e/tests/settings/settings-security-reveal-srp.spec.ts new file mode 100644 index 000000000000..6ee57ae02490 --- /dev/null +++ b/test/e2e/tests/settings/settings-security-reveal-srp.spec.ts @@ -0,0 +1,104 @@ +import { withFixtures } from '../../helpers'; +import FixtureBuilder from '../../fixture-builder'; +import { E2E_SRP } from '../../default-fixture'; +import HeaderNavbar from '../../page-objects/pages/header-navbar'; +import HomePage from '../../page-objects/pages/homepage'; +import PrivacySettings from '../../page-objects/pages/settings/privacy-settings'; +import SettingsPage from '../../page-objects/pages/settings/settings-page'; +import { loginWithBalanceValidation } from '../../page-objects/flows/login.flow'; + +describe('Reveal SRP through settings', function () { + const testPassword = 'correct horse battery staple'; + const wrongTestPassword = 'test test test test'; + + it('should not reveal SRP text with incorrect password', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + + // navigate to security & privacy settings + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp( + wrongTestPassword, + 'Incorrect password', + ); + }, + ); + }); + + it('completes quiz and reveals SRP text', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + + // navigate to security & privacy settings + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + + // fill password to reveal SRP and check the displayed SRP + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp(testPassword); + await privacySettings.check_srpTextIsDisplayed(E2E_SRP); + await privacySettings.check_displayedSrpCanBeCopied(); + + // check that closing the reveal SRP dialog navigates user back to wallet view + await privacySettings.closeRevealSrpDialog(); + await new HomePage(driver).check_pageIsLoaded(); + }, + ); + }); + + it('completes quiz and reveals SRP QR after wrong answers in quiz', async function () { + await withFixtures( + { + fixtures: new FixtureBuilder().build(), + title: this.test?.fullTitle(), + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + + // Navigate to security & privacy settings + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + + // fill password to reveal SRP and check the displayed SRP QR code + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(true); + await privacySettings.fillPasswordToRevealSrp(testPassword); + await privacySettings.check_srpTextIsDisplayed(E2E_SRP); + await privacySettings.check_srpQrCodeIsDisplayed(); + + // check that closing the reveal SRP dialog navigates user back to wallet view + await privacySettings.closeRevealSrpDialog(); + await new HomePage(driver).check_pageIsLoaded(); + }, + ); + }); +}); diff --git a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts index 457d1ea6c0a1..78d1497dc9cf 100644 --- a/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts +++ b/test/e2e/tests/smart-transactions/mock-requests-for-swap-test.ts @@ -1,5 +1,7 @@ import { MockttpServer } from 'mockttp'; import { mockEthDaiTrade } from '../swaps/shared'; +import { mockMultiNetworkBalancePolling } from '../../mock-balance-polling/mock-balance-polling'; +import { mockServerJsonRpc } from '../ppom/mocks/mock-server-json-rpc'; const STX_UUID = '0d506aaa-5e38-4cab-ad09-2039cb7a0f33'; @@ -288,18 +290,14 @@ const GET_TRANSACTION_BY_HASH_RESPONSE = { }; export const mockSwapRequests = async (mockServer: MockttpServer) => { - await mockEthDaiTrade(mockServer); + await mockMultiNetworkBalancePolling(mockServer); - await mockServer - .forJsonRpcRequest({ - method: 'eth_getBalance', - params: ['0x5cfe73b6021e818b776b421b1c4db2474086a7e1'], - }) - .thenJson(200, { - id: 3806592044086814, - jsonrpc: '2.0', - result: '0x1bc16d674ec80000', // 2 ETH - }); + await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], + ['eth_chainId', { result: `0x1` }], + ]); + await mockEthDaiTrade(mockServer); await mockServer .forPost('https://transaction.api.cx.metamask.io/networks/1/getFees') diff --git a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts index 210d5abdb034..36324b9ea797 100644 --- a/test/e2e/tests/smart-transactions/smart-transactions.spec.ts +++ b/test/e2e/tests/smart-transactions/smart-transactions.spec.ts @@ -7,7 +7,6 @@ import { import FixtureBuilder from '../../fixture-builder'; import { unlockWallet, withFixtures } from '../../helpers'; import { Driver } from '../../webdriver/driver'; -import { CHAIN_IDS } from '../../../../shared/constants/network'; import { mockSwapRequests } from './mock-requests-for-swap-test'; export async function withFixturesForSmartTransactions( @@ -20,10 +19,9 @@ export async function withFixturesForSmartTransactions( }, test: (args: { driver: Driver }) => Promise, ) { - const inputChainId = CHAIN_IDS.MAINNET; await withFixtures( { - fixtures: new FixtureBuilder({ inputChainId }) + fixtures: new FixtureBuilder() .withPermissionControllerConnectedToTestDapp() .withPreferencesControllerSmartTransactionsOptedIn() .withNetworkControllerOnMainnet() diff --git a/test/e2e/tests/tokens/add-hide-token.spec.js b/test/e2e/tests/tokens/add-hide-token.spec.js index 5eb60d3db17b..c9a1f26ad9eb 100644 --- a/test/e2e/tests/tokens/add-hide-token.spec.js +++ b/test/e2e/tests/tokens/add-hide-token.spec.js @@ -109,6 +109,15 @@ describe('Add existing token using search', function () { { fixtures: new FixtureBuilder({ inputChainId: CHAIN_IDS.BSC }) .withPreferencesController({ useTokenDetection: true }) + .withTokenListController({ + tokenList: [ + { + name: 'Basic Attention Token', + symbol: 'BAT', + address: '0x0d8775f648430679a709e98d2b0cb6250d2887ef', + }, + ], + }) .build(), ganacheOptions: { ...defaultGanacheOptions, @@ -120,7 +129,8 @@ describe('Add existing token using search', function () { async ({ driver }) => { await unlockWallet(driver); - await driver.clickElement({ text: 'Import', tag: 'button' }); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await driver.fill('input[placeholder="Search tokens"]', 'BAT'); await driver.clickElement({ text: 'BAT', diff --git a/test/e2e/tests/tokens/custom-token-add-approve.spec.js b/test/e2e/tests/tokens/custom-token-add-approve.spec.js index 7a59243da403..4e85aae76fd6 100644 --- a/test/e2e/tests/tokens/custom-token-add-approve.spec.js +++ b/test/e2e/tests/tokens/custom-token-add-approve.spec.js @@ -35,7 +35,8 @@ describe('Create token, approve token and approve token without gas', function ( ); await clickNestedButton(driver, 'Tokens'); - await driver.clickElement({ text: 'Import', tag: 'button' }); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/import-tokens.spec.js b/test/e2e/tests/tokens/import-tokens.spec.js index a1eb2782f9db..7b1bf60964ab 100644 --- a/test/e2e/tests/tokens/import-tokens.spec.js +++ b/test/e2e/tests/tokens/import-tokens.spec.js @@ -37,7 +37,28 @@ describe('Import flow', function () { it('allows importing multiple tokens from search', async function () { await withFixtures( { - fixtures: new FixtureBuilder().withNetworkControllerOnMainnet().build(), + fixtures: new FixtureBuilder() + .withNetworkControllerOnMainnet() + .withTokensController({ + tokenList: [ + { + name: 'Chain Games', + symbol: 'CHAIN', + address: '0xc4c2614e694cf534d407ee49f8e44d125e4681c4', + }, + { + address: '0x7051faed0775f664a0286af4f75ef5ed74e02754', + symbol: 'CHANGE', + name: 'ChangeX', + }, + { + name: 'Chai', + symbol: 'CHAI', + address: '0x06af07097c9eeb7fd685c692751d5c66db49c215', + }, + ], + }) + .build(), ganacheOptions: defaultGanacheOptions, title: this.test.fullTitle(), testSpecificMock: mockPriceFetch, @@ -48,6 +69,7 @@ describe('Import flow', function () { await driver.assertElementNotPresent('.loading-overlay'); await driver.clickElement('[data-testid="import-token-button"]'); + await driver.clickElement('[data-testid="importTokens"]'); await driver.fill('input[placeholder="Search tokens"]', 'cha'); diff --git a/test/e2e/tests/tokens/nft/auto-detect-nft.spec.js b/test/e2e/tests/tokens/nft/auto-detect-nft.spec.js deleted file mode 100644 index ccd15ce0f71b..000000000000 --- a/test/e2e/tests/tokens/nft/auto-detect-nft.spec.js +++ /dev/null @@ -1,57 +0,0 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - openMenuSafe, - unlockWallet, - withFixtures, -} = require('../../../helpers'); -const FixtureBuilder = require('../../../fixture-builder'); -const { setupAutoDetectMocking } = require('./mocks'); - -describe('NFT detection', function () { - /** - * TODO Revisit this test once we enable nft auto detection by default. Use .withPreferencesControllerNftDetectionEnabled(). - */ - it('displays NFT media', async function () { - const driverOptions = { mock: true }; - await withFixtures( - { - fixtures: new FixtureBuilder().withNetworkControllerOnMainnet().build(), - driverOptions, - ganacheOptions: defaultGanacheOptions, - title: this.test.fullTitle(), - testSpecificMock: setupAutoDetectMocking, - }, - async ({ driver }) => { - await unlockWallet(driver); - - // go to settings - await openMenuSafe(driver); - - await driver.clickElement({ text: 'Settings', tag: 'div' }); - await driver.clickElement({ text: 'Security & privacy', tag: 'div' }); - await driver.clickElement( - '[data-testid="useNftDetection"] .toggle-button > div', - ); - await driver.clickElement( - '.settings-page__header__title-container__close-button', - ); - await driver.clickElement( - '[data-testid="account-overview__asset-tab"]', - ); - - await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); - await driver.delay(1000); - const collection = await driver.findElement( - '[data-testid="collection-expander-button"]', - ); - const nftImage = await driver.findElement('[data-testid="nft-item"]'); - assert.equal( - await collection.getText(), - 'ENS: Ethereum Name Service (1)', - ); - assert.equal(await nftImage.isDisplayed(), true); - }, - ); - }); -}); diff --git a/test/e2e/tests/tokens/nft/auto-detect-nft.spec.ts b/test/e2e/tests/tokens/nft/auto-detect-nft.spec.ts new file mode 100644 index 000000000000..c70a3860dd63 --- /dev/null +++ b/test/e2e/tests/tokens/nft/auto-detect-nft.spec.ts @@ -0,0 +1,49 @@ +import { withFixtures } from '../../../helpers'; +import FixtureBuilder from '../../../fixture-builder'; +import HeaderNavbar from '../../../page-objects/pages/header-navbar'; +import Homepage from '../../../page-objects/pages/homepage'; +import PrivacySettings from '../../../page-objects/pages/settings/privacy-settings'; +import SettingsPage from '../../../page-objects/pages/settings/settings-page'; +import { loginWithBalanceValidation } from '../../../page-objects/flows/login.flow'; +import { setupAutoDetectMocking } from './mocks'; + +describe('NFT detection', function () { + /** + * TODO Revisit this test once we enable nft auto detection by default. Use .withPreferencesControllerNftDetectionEnabled(). + */ + it('displays NFT media', async function () { + const driverOptions = { mock: true }; + await withFixtures( + { + fixtures: new FixtureBuilder().withNetworkControllerOnMainnet().build(), + driverOptions, + title: this.test?.fullTitle(), + testSpecificMock: setupAutoDetectMocking, + }, + async ({ driver }) => { + await loginWithBalanceValidation(driver); + + // navigate to security & privacy settings and toggle on NFT autodetection + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.toggleAutodetectNft(); + await settingsPage.closeSettingsPage(); + + // check that nft is displayed + const homepage = new Homepage(driver); + await homepage.check_pageIsLoaded(); + await homepage.check_expectedBalanceIsDisplayed(); + await homepage.goToNftTab(); + await homepage.check_nftNameIsDisplayed( + 'ENS: Ethereum Name Service (1)', + ); + await homepage.check_nftImageIsDisplayed(); + }, + ); + }); +}); diff --git a/test/e2e/tests/tokens/nft/import-nft.spec.js b/test/e2e/tests/tokens/nft/import-nft.spec.js deleted file mode 100644 index 7d10c0fba3c6..000000000000 --- a/test/e2e/tests/tokens/nft/import-nft.spec.js +++ /dev/null @@ -1,189 +0,0 @@ -const { strict: assert } = require('assert'); -const { - defaultGanacheOptions, - withFixtures, - unlockWallet, - findAnotherAccountFromAccountList, - locateAccountBalanceDOM, -} = require('../../../helpers'); -const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); -const FixtureBuilder = require('../../../fixture-builder'); - -describe('Import NFT', function () { - const smartContract = SMART_CONTRACTS.NFTS; - - it('should be able to import an NFT that user owns', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), - ganacheOptions: defaultGanacheOptions, - smartContract, - title: this.test.fullTitle(), - }, - async ({ driver, _, contractRegistry }) => { - const contractAddress = - contractRegistry.getContractAddress(smartContract); - await unlockWallet(driver); - - // After login, go to NFTs tab, open the import NFT form - await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); - await driver.clickElement({ text: 'Import NFT', tag: 'button' }); - - // Enter a valid NFT that belongs to user and check success message appears - await driver.fill('#address', contractAddress); - await driver.fill('#token-id', '1'); - await driver.clickElement( - '[data-testid="import-nfts-modal-import-button"]', - ); - - const newNftNotification = await driver.findElement({ - text: 'NFT was successfully added!', - tag: 'h6', - }); - assert.equal(await newNftNotification.isDisplayed(), true); - - // Check the imported NFT and its image are displayed in the NFT tab - const importedNft = await driver.waitForSelector({ - css: 'h5', - text: 'TestDappNFTs', - }); - const importedNftImage = await driver.findElement( - '.nft-item__container', - ); - assert.equal(await importedNft.isDisplayed(), true); - assert.equal(await importedNftImage.isDisplayed(), true); - }, - ); - }); - - it('should continue to display an imported NFT after importing, adding a new account, and switching back', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), - ganacheOptions: defaultGanacheOptions, - smartContract, - title: this.test.fullTitle(), - }, - async ({ driver, ganacheServer, contractRegistry }) => { - const contractAddress = - contractRegistry.getContractAddress(smartContract); - await unlockWallet(driver); - - // After login, go to NFTs tab, open the import NFT form - await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); - await driver.clickElement({ text: 'Import NFT', tag: 'button' }); - - // Enter a valid NFT that belongs to user and check success message appears - await driver.fill('#address', contractAddress); - await driver.fill('#token-id', '1'); - await driver.clickElementAndWaitToDisappear( - '[data-testid="import-nfts-modal-import-button"]', - ); - - const newNftNotification = await driver.findElement({ - text: 'NFT was successfully added!', - tag: 'h6', - }); - assert.equal(await newNftNotification.isDisplayed(), true); - - // Check the imported NFT and its image are displayed in the NFT tab - const importedNft = await driver.waitForSelector({ - css: 'h5', - text: 'TestDappNFTs', - }); - const importedNftImage = await driver.findElement( - '.nft-item__container', - ); - assert.equal(await importedNft.isDisplayed(), true); - assert.equal(await importedNftImage.isDisplayed(), true); - - await driver.clickElement('[data-testid="account-menu-icon"]'); - await driver.clickElement( - '[data-testid="multichain-account-menu-popover-action-button"]', - ); - await driver.clickElement({ - text: 'Add a new Ethereum account', - tag: 'button', - }); - - // By clicking creating button without filling in the account name - // the default name would be set as Account 2 - await driver.clickElement( - '[data-testid="submit-add-account-with-name"]', - ); - - await driver.isElementPresent({ - tag: 'span', - text: 'Account 2', - }); - const accountOneSelector = await findAnotherAccountFromAccountList( - driver, - 1, - 'Account 1', - ); - - await driver.clickElement(accountOneSelector); - await locateAccountBalanceDOM(driver, ganacheServer); - const nftIsStillDisplayed = await driver.isElementPresentAndVisible({ - css: 'h5', - text: 'TestDappNFTs', - }); - const nftImageIsStillDisplayed = - await driver.isElementPresentAndVisible('.nft-item__container'); - assert.equal( - nftIsStillDisplayed, - true, - 'Nft is no longer displayed after adding an account and switching back to account 1', - ); - assert.equal( - nftImageIsStillDisplayed, - true, - 'Nft image is no longer displayed after adding an account and switching back to account 1', - ); - }, - ); - }); - - it('should not be able to import an NFT that does not belong to user', async function () { - await withFixtures( - { - dapp: true, - fixtures: new FixtureBuilder() - .withPermissionControllerConnectedToTestDapp() - .build(), - ganacheOptions: defaultGanacheOptions, - smartContract, - title: this.test.fullTitle(), - }, - async ({ driver, _, contractRegistry }) => { - const contractAddress = - contractRegistry.getContractAddress(smartContract); - await unlockWallet(driver); - - // After login, go to NFTs tab, open the import NFT form - await driver.clickElement('[data-testid="account-overview__nfts-tab"]'); - await driver.clickElement({ text: 'Import NFT', tag: 'button' }); - - // Enter an NFT that not belongs to user with a valid address and an invalid token id - await driver.fill('#address', contractAddress); - await driver.fill('#token-id', '2'); - await driver.clickElement( - '[data-testid="import-nfts-modal-import-button"]', - ); - - // Check error message appears - const invalidNftNotification = await driver.findElement({ - text: 'NFT can’t be added as the ownership details do not match. Make sure you have entered correct information.', - tag: 'p', - }); - assert.equal(await invalidNftNotification.isDisplayed(), true); - }, - ); - }); -}); diff --git a/test/e2e/tests/tokens/nft/import-nft.spec.ts b/test/e2e/tests/tokens/nft/import-nft.spec.ts new file mode 100644 index 000000000000..73049a8c9d0d --- /dev/null +++ b/test/e2e/tests/tokens/nft/import-nft.spec.ts @@ -0,0 +1,111 @@ +import { defaultGanacheOptions, withFixtures } from '../../../helpers'; +import { SMART_CONTRACTS } from '../../../seeder/smart-contracts'; +import FixtureBuilder from '../../../fixture-builder'; +import AccountListPage from '../../../page-objects/pages/account-list-page'; +import HeaderNavbar from '../../../page-objects/pages/header-navbar'; +import Homepage from '../../../page-objects/pages/homepage'; +import { loginWithBalanceValidation } from '../../../page-objects/flows/login.flow'; + +describe('Import NFT', function () { + const smartContract = SMART_CONTRACTS.NFTS; + + it('should be able to import an NFT that user owns', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer, contractRegistry }) => { + const contractAddress = + contractRegistry.getContractAddress(smartContract); + await loginWithBalanceValidation(driver, ganacheServer); + + const homepage = new Homepage(driver); + await homepage.goToNftTab(); + await homepage.importNft(contractAddress, '1'); + await homepage.check_successImportNftMessageIsDisplayed(); + + await homepage.check_nftNameIsDisplayed('TestDappNFTs'); + await homepage.check_nftImageIsDisplayed(); + }, + ); + }); + + it('should continue to display an imported NFT after importing, adding a new account, and switching back', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer, contractRegistry }) => { + const contractAddress = + contractRegistry.getContractAddress(smartContract); + await loginWithBalanceValidation(driver, ganacheServer); + + // Import a NFT and check that it is displayed in the NFT tab on homepage + const homepage = new Homepage(driver); + await homepage.goToNftTab(); + await homepage.importNft(contractAddress, '1'); + await homepage.check_successImportNftMessageIsDisplayed(); + await homepage.check_nftNameIsDisplayed('TestDappNFTs'); + await homepage.check_nftImageIsDisplayed(); + + // Create new account with default name Account 2 + const headerNavbar = new HeaderNavbar(driver); + await headerNavbar.openAccountMenu(); + const accountListPage = new AccountListPage(driver); + await accountListPage.check_pageIsLoaded(); + await accountListPage.addNewAccountWithDefaultName(); + await headerNavbar.check_accountLabel('Account 2'); + await homepage.check_expectedBalanceIsDisplayed(); + + // Switch back to Account 1 and check that the NFT is still displayed + await headerNavbar.openAccountMenu(); + await accountListPage.check_pageIsLoaded(); + await accountListPage.check_accountDisplayedInAccountList('Account 1'); + await accountListPage.switchToAccount('Account 1'); + await headerNavbar.check_accountLabel('Account 1'); + await homepage.check_ganacheBalanceIsDisplayed(ganacheServer); + await homepage.check_nftNameIsDisplayed('TestDappNFTs'); + await homepage.check_nftImageIsDisplayed(); + }, + ); + }); + + it('should not be able to import an NFT that does not belong to user', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + title: this.test?.fullTitle(), + }, + async ({ driver, ganacheServer, contractRegistry }) => { + const contractAddress = + contractRegistry.getContractAddress(smartContract); + await loginWithBalanceValidation(driver, ganacheServer); + + const homepage = new Homepage(driver); + await homepage.goToNftTab(); + await homepage.importNft( + contractAddress, + '2', + 'NFT can’t be added as the ownership details do not match. Make sure you have entered correct information.', + ); + }, + ); + }); +}); diff --git a/test/e2e/tests/tokens/token-details.spec.ts b/test/e2e/tests/tokens/token-details.spec.ts index 0d577ab20f19..2ee84d339ea8 100644 --- a/test/e2e/tests/tokens/token-details.spec.ts +++ b/test/e2e/tests/tokens/token-details.spec.ts @@ -27,7 +27,8 @@ describe('Token Details', function () { }; const importToken = async (driver: Driver) => { - await driver.clickElement({ text: 'Import', tag: 'button' }); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-list.spec.ts b/test/e2e/tests/tokens/token-list.spec.ts index bffef04c40dd..f7b032c92a4c 100644 --- a/test/e2e/tests/tokens/token-list.spec.ts +++ b/test/e2e/tests/tokens/token-list.spec.ts @@ -27,7 +27,8 @@ describe('Token List', function () { }; const importToken = async (driver: Driver) => { - await driver.clickElement({ text: 'Import', tag: 'button' }); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/tokens/token-sort.spec.ts b/test/e2e/tests/tokens/token-sort.spec.ts index e0d335ee0fd6..ed6005a710ad 100644 --- a/test/e2e/tests/tokens/token-sort.spec.ts +++ b/test/e2e/tests/tokens/token-sort.spec.ts @@ -25,7 +25,8 @@ describe('Token List', function () { }; const importToken = async (driver: Driver) => { - await driver.clickElement({ text: 'Import', tag: 'button' }); + await driver.clickElement(`[data-testid="import-token-button"]`); + await driver.clickElement(`[data-testid="importTokens"]`); await clickNestedButton(driver, 'Custom token'); await driver.fill( '[data-testid="import-tokens-modal-custom-address"]', diff --git a/test/e2e/tests/transaction/ens.spec.ts b/test/e2e/tests/transaction/ens.spec.ts index 47bae3e5e4cc..3291287ab5a4 100644 --- a/test/e2e/tests/transaction/ens.spec.ts +++ b/test/e2e/tests/transaction/ens.spec.ts @@ -7,6 +7,7 @@ import { loginWithoutBalanceValidation } from '../../page-objects/flows/login.fl import HomePage from '../../page-objects/pages/homepage'; import SendTokenPage from '../../page-objects/pages/send/send-token-page'; import { mockServerJsonRpc } from '../ppom/mocks/mock-server-json-rpc'; +import { mockMultiNetworkBalancePolling } from '../../mock-balance-polling/mock-balance-polling'; describe('ENS', function (this: Suite) { const sampleAddress: string = '1111111111111111111111111111111111111111'; @@ -15,82 +16,60 @@ describe('ENS', function (this: Suite) { const shortSampleAddress = '0x1111...1111'; const shortSampleAddresV2 = '0x11111...11111'; const chainId = 1; - const mockResolver = '226159d592e2b063810a10ebf6dcbada94ed68b8'; + // ENS Contract Addresses and Function Signatures + const ENSRegistryWithFallback: string = + '0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e'; + const resolverSignature: string = '0x0178b8bf'; + const ensNode: string = + 'eb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1'; + const resolverNodeAddress: string = + '226159d592e2b063810a10ebf6dcbada94ed68b8'; + const supportsInterfaceSignature: string = '0x01ffc9a7'; + const addressSignature: string = '0x3b3b57de'; const sampleEnsDomain: string = 'test.eth'; - const infuraUrl: string = - 'https://mainnet.infura.io/v3/00000000000000000000000000000000'; async function mockInfura(mockServer: MockttpServer): Promise { - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_blockNumber' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: '0x1', - }, - })); - - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_getBalance' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: '0x1', - }, - })); - - await mockServer - .forPost(infuraUrl) - .withJsonBodyIncluding({ method: 'eth_getBlockByNumber' }) - .thenCallback(() => ({ - statusCode: 200, - json: { - jsonrpc: '2.0', - id: '1111111111111111', - result: {}, - }, - })); + await mockMultiNetworkBalancePolling(mockServer); await mockServerJsonRpc(mockServer, [ + ['eth_blockNumber'], + ['eth_getBlockByNumber'], ['eth_chainId', { result: `0x${chainId}` }], + // 1. Get the address of the resolver for the specified node [ 'eth_call', { params: [ { - to: '0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e', - data: '0x0178b8bfeb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1', + to: ENSRegistryWithFallback, + data: `${resolverSignature}${ensNode}`, }, ], - result: `0x000000000000000000000000${mockResolver}`, + result: `0x000000000000000000000000${resolverNodeAddress}`, }, ], + // 2. Check supportsInterface from the public resolver [ 'eth_call', { params: [ { - to: `0x${mockResolver}`, - data: '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000', + to: `0x${resolverNodeAddress}`, + data: `${supportsInterfaceSignature}9061b92300000000000000000000000000000000000000000000000000000000`, }, ], result: `0x0000000000000000000000000000000000000000000000000000000000000000`, }, ], + // 3. Return the address associated with an ENS [ 'eth_call', { params: [ { - to: `0x${mockResolver}`, - data: '0x3b3b57deeb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1', + to: `0x${resolverNodeAddress}`, + data: `${addressSignature}eb4f647bea6caa36333c816d7b46fdcb05f9466ecacc140ea8c66faf15b3d9f1`, }, ], result: `0x000000000000000000000000${sampleAddress}`, @@ -113,7 +92,7 @@ describe('ENS', function (this: Suite) { // click send button on homepage to start send flow const homepage = new HomePage(driver); await homepage.check_pageIsLoaded(); - await homepage.check_expectedBalanceIsDisplayed('<0.000001'); + await homepage.check_expectedBalanceIsDisplayed('20'); await homepage.startSendFlow(); // fill ens address as recipient when user lands on send token screen diff --git a/test/e2e/vault-decryption-chrome.spec.js b/test/e2e/vault-decryption-chrome.spec.js deleted file mode 100644 index aa2b223dce56..000000000000 --- a/test/e2e/vault-decryption-chrome.spec.js +++ /dev/null @@ -1,293 +0,0 @@ -const { strict: assert } = require('assert'); -const os = require('os'); -const path = require('path'); -const fs = require('fs-extra'); -const level = require('level'); -const { - withFixtures, - WALLET_PASSWORD, - openSRPRevealQuiz, - completeSRPRevealQuiz, - tapAndHoldToRevealSRP, - completeCreateNewWalletOnboardingFlowWithOptOut, -} = require('./helpers'); - -const VAULT_DECRYPTOR_PAGE = 'https://metamask.github.io/vault-decryptor'; - -/** - * Copies a directory to a temporary location. - * - * @param {string} srcDir - The source directory to copy. - * @returns {Promise} The path to the copied directory in the temporary location. - */ -async function copyDirectoryToTmp(srcDir) { - try { - // Get a temporary directory - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'temp')); - - // Define the destination path in the temporary directory - const destDir = path.join(tmpDir, path.basename(srcDir)); - - // Copy the directory - await fs.copy(srcDir, destDir); - console.log(`Directory copied to: ${destDir}`); - return destDir; - } catch (err) { - console.error('Error copying directory:', err); - return null; - } -} - -/** - * Retrieve the extension's storage file path. - * - * Note that this folder is usually unavailable when running e2e tests - * on a test build, as test builds do not use the real browser storage. - * - * @param {WebDriver} driver - * @returns {Promise} The extension storage path. - */ -async function getExtensionStorageFilePath(driver) { - const { userDataDir } = (await driver.driver.getCapabilities()).get('chrome'); - const extensionsStoragePath = path.resolve( - userDataDir, - 'Default', - 'Local Extension Settings', - ); - // we expect the extension to have been installed only once - const extensionName = fs.readdirSync(extensionsStoragePath)[0]; - const extensionStoragePath = path.resolve( - extensionsStoragePath, - extensionName, - ); - - return extensionStoragePath; -} - -/** - * Retrieve the log file from the extension's storage path. - * - * @param {string} extensionStoragePath - The path to the extension's storage. - * @returns {string} The log file path. - */ -function getExtensionLogFile(extensionStoragePath) { - const logFiles = fs - .readdirSync(extensionStoragePath) - .filter((filename) => filename.endsWith('.log')); - - // Use the first of the `.log` files found - return path.resolve(extensionStoragePath, logFiles[0]); -} - -/** - * Gets the size of a file in bytes. - * - * @param {string} filePath - The path to the file. - * @returns {Promise} A promise that resolves to the size of the file in bytes. - */ -async function getFileSize(filePath) { - const stats = await fs.promises.stat(filePath); - console.log(`File Size =========================: ${stats.size} bytes`); - return stats.size; -} - -/** - * Retry logic to ensure Chrome has finish writing into the file. - * - * @param {object} options - The options object. - * @param {WebDriver} options.driver - The WebDriver instance. - * @param {string} options.filePath - The path to the file. - * @param {number} options.maxRetries - The maximum number of retries. - * @param {number} options.minFileSize - The minimum file size in bytes. - * @returns {Promise} - */ -async function waitUntilFileIsWritten({ - driver, - filePath, - maxRetries = 3, - minFileSize = 1000000, -}) { - for (let attempt = 0; attempt < maxRetries; attempt++) { - const fileSize = await getFileSize(filePath); - if (fileSize > minFileSize) { - break; - } else { - console.log(`File size is too small (${fileSize} bytes)`); - if (attempt < maxRetries - 1) { - console.log(`Waiting for 2 seconds before retrying...`); - await driver.delay(2000); - } - } - } -} - -/** - * Closes the announcements popover if present - * - * @param {WebDriver} driver - */ -async function closePopoverIfPresent(driver) { - const popoverButtonSelector = '[data-testid="popover-close"]'; - // It shows in the Smart Transactions Opt-In Modal. - const enableButtonSelector = { - text: 'Enable', - tag: 'button', - }; - await driver.clickElementSafe(popoverButtonSelector); - await driver.clickElementSafe(enableButtonSelector); - - // Token Autodetection Independent Announcement - const tokenAutodetection = { - css: '[data-testid="auto-detect-token-modal"] button', - text: 'Not right now', - }; - await driver.clickElementSafe(tokenAutodetection); - - // NFT Autodetection Independent Announcement - const nftAutodetection = { - css: '[data-testid="auto-detect-nft-modal"] button', - text: 'Not right now', - }; - await driver.clickElementSafe(nftAutodetection); -} - -/** - * Obtain the SRP from the settings - * - * @param {WebDriver} driver - * @returns {Promise} The SRP - */ -async function getSRP(driver) { - await openSRPRevealQuiz(driver); - await completeSRPRevealQuiz(driver); - await driver.fill('[data-testid="input-password"]', WALLET_PASSWORD); - await driver.press('[data-testid="input-password"]', driver.Key.ENTER); - await tapAndHoldToRevealSRP(driver); - return (await driver.findElement('[data-testid="srp_text"]')).getText(); -} - -describe('Vault Decryptor Page', function () { - it('is able to decrypt the vault uploading the log file in the vault-decryptor webapp', async function () { - await withFixtures( - { - disableServerMochaToBackground: true, - }, - async ({ driver }) => { - // we don't need to use navigate - // since MM will automatically open a new window in prod build - await driver.waitUntilXWindowHandles(2); - - // we cannot use the customized driver functions - // as there is no socket for window communications in prod builds - const windowHandles = await driver.driver.getAllWindowHandles(); - - // switch to MetaMask window - await driver.driver.switchTo().window(windowHandles[2]); - - // create a new vault through onboarding flow - await completeCreateNewWalletOnboardingFlowWithOptOut( - driver, - WALLET_PASSWORD, - ); - // close popover if any (Announcements etc..) - await closePopoverIfPresent(driver); - // obtain SRP - const seedPhrase = await getSRP(driver); - - // navigate to the Vault decryptor webapp - await driver.openNewPage(VAULT_DECRYPTOR_PAGE); - // fill the input field with storage recovered from filesystem - await driver.clickElement('[name="vault-source"]'); - const inputField = await driver.findElement('#fileinput'); - - // Retry-logic to ensure the file is ready before uploading it - // to mitigate flakiness when Chrome hasn't finished writing - const extensionPath = await getExtensionStorageFilePath(driver); - const extensionLogFile = getExtensionLogFile(extensionPath); - await waitUntilFileIsWritten({ driver, filePath: extensionLogFile }); - - await inputField.press(extensionLogFile); - - // fill in the password - await driver.fill('#passwordinput', WALLET_PASSWORD); - // decrypt - await driver.clickElement('.decrypt'); - const decrypted = await driver.findElement('.content div div div'); - const recoveredVault = JSON.parse(await decrypted.getText()); - - assert.equal(recoveredVault[0].data.mnemonic, seedPhrase); - }, - ); - }); - it('is able to decrypt the vault pasting the text in the vault-decryptor webapp', async function () { - await withFixtures( - { - disableServerMochaToBackground: true, - }, - async ({ driver }) => { - // we don't need to use navigate - // since MM will automatically open a new window in prod build - await driver.waitUntilXWindowHandles(2); - - // we cannot use the customized driver functions - // as there is no socket for window communications in prod builds - const windowHandles = await driver.driver.getAllWindowHandles(); - - // switch to MetaMask window - await driver.driver.switchTo().window(windowHandles[2]); - - // create a new vault through onboarding flow - await completeCreateNewWalletOnboardingFlowWithOptOut( - driver, - WALLET_PASSWORD, - ); - // close popover if any (Announcements etc..) - await closePopoverIfPresent(driver); - // obtain SRP - const seedPhrase = await getSRP(driver); - - // navigate to the Vault decryptor webapp - await driver.openNewPage(VAULT_DECRYPTOR_PAGE); - - // retry-logic to ensure the file is written before copying it - const extensionPath = await getExtensionStorageFilePath(driver); - const extensionLogFile = getExtensionLogFile(extensionPath); - await waitUntilFileIsWritten({ driver, filePath: extensionLogFile }); - - // copy log file to a temp location, to avoid reading it while the browser is writting it - let newDir; - let vaultObj; - let db; - try { - newDir = await copyDirectoryToTmp(extensionPath); - db = new level.Level(newDir, { valueEncoding: 'json' }); - await db.open(); - const { - KeyringController: { vault }, - } = await db.get('data'); - vaultObj = JSON.parse(vault); - } finally { - if (db) { - await db.close(); - } - if (newDir) { - await fs.remove(newDir); - } - } - - await driver.clickElement('#radio-textinput'); - await driver.fill('#textinput', JSON.stringify(vaultObj)); - - // fill in the password - await driver.fill('#passwordinput', WALLET_PASSWORD); - - // decrypt - await driver.clickElement('.decrypt'); - const decrypted = await driver.findElement('.content div div div'); - const recoveredVault = JSON.parse(await decrypted.getText()); - - assert.equal(recoveredVault[0].data.mnemonic, seedPhrase); - }, - ); - }); -}); diff --git a/test/e2e/vault-decryption-chrome.spec.ts b/test/e2e/vault-decryption-chrome.spec.ts new file mode 100644 index 000000000000..939494b00a60 --- /dev/null +++ b/test/e2e/vault-decryption-chrome.spec.ts @@ -0,0 +1,298 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import level from 'level'; +import { Driver } from './webdriver/driver'; +import { withFixtures, WALLET_PASSWORD } from './helpers'; +import HeaderNavbar from './page-objects/pages/header-navbar'; +import HomePage from './page-objects/pages/homepage'; +import PrivacySettings from './page-objects/pages/settings/privacy-settings'; +import SettingsPage from './page-objects/pages/settings/settings-page'; +import VaultDecryptorPage from './page-objects/pages/vault-decryptor-page'; +import { completeCreateNewWalletOnboardingFlowWithCustomSettings } from './page-objects/flows/onboarding.flow'; + +const VAULT_DECRYPTOR_PAGE = 'https://metamask.github.io/vault-decryptor'; + +/** + * Copies a directory to a temporary location. + * + * @param srcDir - The source directory to copy. + * @returns The path to the copied directory in the temporary location. + */ +async function copyDirectoryToTmp(srcDir: string): Promise { + try { + // Get a temporary directory + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'temp')); + + // Define the destination path in the temporary directory + const destDir = path.join(tmpDir, path.basename(srcDir)); + + // Copy the directory + await fs.copy(srcDir, destDir); + console.log(`Directory copied to: ${destDir}`); + return destDir; + } catch (err) { + console.error('Error copying directory:', err); + return ''; + } +} + +/** + * Retrieve the extension's storage file path. + * + * Note that this folder is usually unavailable when running e2e tests + * on a test build, as test builds do not use the real browser storage. + * + * @param driver + * @returns The extension storage path. + */ +async function getExtensionStorageFilePath(driver: Driver): Promise { + const { userDataDir } = (await driver.driver.getCapabilities()).get('chrome'); + const extensionsStoragePath = path.resolve( + userDataDir, + 'Default', + 'Local Extension Settings', + ); + // we expect the extension to have been installed only once + const extensionName = fs.readdirSync(extensionsStoragePath)[0]; + const extensionStoragePath = path.resolve( + extensionsStoragePath, + extensionName, + ); + + return extensionStoragePath; +} + +/** + * Retrieve the log file from the extension's storage path. + * + * @param extensionStoragePath - The path to the extension's storage. + * @returns The log file path. + */ +function getExtensionLogFile(extensionStoragePath: string): string { + const logFiles = fs + .readdirSync(extensionStoragePath) + .filter((filename: string) => filename.endsWith('.log')); + + // Use the first of the `.log` files found + return path.resolve(extensionStoragePath, logFiles[0]); +} + +/** + * Gets the size of a file in bytes. + * + * @param filePath - The path to the file. + * @returns A promise that resolves to the size of the file in bytes. + */ +async function getFileSize(filePath: string): Promise { + const stats = await fs.promises.stat(filePath); + console.log(`File Size =========================: ${stats.size} bytes`); + return stats.size; +} + +/** + * Retry logic to ensure Chrome has finish writing into the file. + * + * @param options - The options object. + * @param options.driver - The WebDriver instance. + * @param options.filePath - The path to the file. + * @param options.maxRetries - The maximum number of retries. + * @param options.minFileSize - The minimum file size in bytes. + * @returns + */ +async function waitUntilFileIsWritten({ + driver, + filePath, + maxRetries = 3, + minFileSize = 1000000, +}: { + driver: Driver; + filePath: string; + maxRetries?: number; + minFileSize?: number; +}): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + const fileSize = await getFileSize(filePath); + if (fileSize > minFileSize) { + break; + } else { + console.log(`File size is too small (${fileSize} bytes)`); + if (attempt < maxRetries - 1) { + console.log(`Waiting for 2 seconds before retrying...`); + await driver.delay(2000); + } + } + } +} + +/** + * Closes the announcements popover if present + * + * @param driver + */ +async function closePopoverIfPresent(driver: Driver) { + const popoverButtonSelector = '[data-testid="popover-close"]'; + // It shows in the Smart Transactions Opt-In Modal. + const enableButtonSelector = { + text: 'Enable', + tag: 'button', + }; + await driver.clickElementSafe(popoverButtonSelector); + await driver.clickElementSafe(enableButtonSelector); + + // Token Autodetection Independent Announcement + const tokenAutodetection = { + css: '[data-testid="auto-detect-token-modal"] button', + text: 'Not right now', + }; + await driver.clickElementSafe(tokenAutodetection); + + // NFT Autodetection Independent Announcement + const nftAutodetection = { + css: '[data-testid="auto-detect-nft-modal"] button', + text: 'Not right now', + }; + await driver.clickElementSafe(nftAutodetection); +} + +describe('Vault Decryptor Page', function () { + it('is able to decrypt the vault uploading the log file in the vault-decryptor webapp', async function () { + await withFixtures( + { + disableServerMochaToBackground: true, + }, + async ({ driver }) => { + // we don't need to use navigate since MM will automatically open a new window in prod build + await driver.waitUntilXWindowHandles(2); + + // we cannot use the customized driver functions as there is no socket for window communications in prod builds + const windowHandles = await driver.driver.getAllWindowHandles(); + + // switch to MetaMask window and create a new vault through onboarding flow + await driver.driver.switchTo().window(windowHandles[2]); + await completeCreateNewWalletOnboardingFlowWithCustomSettings({ + driver, + password: WALLET_PASSWORD, + needNavigateToNewPage: false, + }); + // close popover if any (Announcements etc..) + await closePopoverIfPresent(driver); + + // go to privacy settings page + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed(); + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + // fill password to reveal SRP and get the SRP + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp(WALLET_PASSWORD); + const seedPhrase = await privacySettings.getSrpInRevealSrpDialog(); + + // Retry-logic to ensure the file is ready before uploading itto mitigate flakiness when Chrome hasn't finished writing + const extensionPath = await getExtensionStorageFilePath(driver); + const extensionLogFile = getExtensionLogFile(extensionPath); + await waitUntilFileIsWritten({ driver, filePath: extensionLogFile }); + + // navigate to the Vault decryptor webapp and fill the input field with storage recovered from filesystem + await driver.openNewPage(VAULT_DECRYPTOR_PAGE); + const vaultDecryptorPage = new VaultDecryptorPage(driver); + await vaultDecryptorPage.check_pageIsLoaded(); + await vaultDecryptorPage.uploadLogFile(extensionLogFile); + + // fill the password and decrypt + await vaultDecryptorPage.fillPassword(); + await vaultDecryptorPage.confirmDecrypt(); + await vaultDecryptorPage.check_vaultIsDecrypted(seedPhrase); + }, + ); + }); + + it('is able to decrypt the vault pasting the text in the vault-decryptor webapp', async function () { + await withFixtures( + { + disableServerMochaToBackground: true, + }, + async ({ driver }) => { + // we don't need to use navigate since MM will automatically open a new window in prod build + await driver.waitUntilXWindowHandles(2); + + // we cannot use the customized driver functions as there is no socket for window communications in prod builds + const windowHandles = await driver.driver.getAllWindowHandles(); + + // switch to MetaMask window and create a new vault through onboarding flow + await driver.driver.switchTo().window(windowHandles[2]); + await completeCreateNewWalletOnboardingFlowWithCustomSettings({ + driver, + password: WALLET_PASSWORD, + needNavigateToNewPage: false, + }); + // close popover if any (Announcements etc..) + await closePopoverIfPresent(driver); + + // go to privacy settings page + const homePage = new HomePage(driver); + await homePage.check_pageIsLoaded(); + await homePage.check_expectedBalanceIsDisplayed(); + await new HeaderNavbar(driver).openSettingsPage(); + const settingsPage = new SettingsPage(driver); + await settingsPage.check_pageIsLoaded(); + await settingsPage.goToPrivacySettings(); + + // fill password to reveal SRP and get the SRP + const privacySettings = new PrivacySettings(driver); + await privacySettings.check_pageIsLoaded(); + await privacySettings.openRevealSrpQuiz(); + await privacySettings.completeRevealSrpQuiz(); + await privacySettings.fillPasswordToRevealSrp(WALLET_PASSWORD); + const seedPhrase = await privacySettings.getSrpInRevealSrpDialog(); + + // retry-logic to ensure the file is written before copying it + const extensionPath = await getExtensionStorageFilePath(driver); + const extensionLogFile = getExtensionLogFile(extensionPath); + await waitUntilFileIsWritten({ driver, filePath: extensionLogFile }); + + // copy log file to a temp location, to avoid reading it while the browser is writting it + type VaultData = { + KeyringController: { + vault: string; + }; + }; + let newDir; + let vaultObj; + let db; + try { + newDir = await copyDirectoryToTmp(extensionPath); + db = new level.Level(newDir, { valueEncoding: 'json' }); + await db.open(); + const data = (await db.get('data')) as unknown as VaultData; + vaultObj = JSON.parse(data.KeyringController.vault); + } finally { + if (db) { + await db.close(); + } + if (newDir) { + await fs.remove(newDir); + } + } + + // navigate to the Vault decryptor webapp and fill the text input field with the vault text + await driver.openNewPage(VAULT_DECRYPTOR_PAGE); + const vaultDecryptorPage = new VaultDecryptorPage(driver); + await vaultDecryptorPage.check_pageIsLoaded(); + await vaultDecryptorPage.fillVaultText(JSON.stringify(vaultObj)); + + // fill the password and decrypt + await vaultDecryptorPage.fillPassword(); + await vaultDecryptorPage.confirmDecrypt(); + await vaultDecryptorPage.check_vaultIsDecrypted(seedPhrase); + }, + ); + }); +}); diff --git a/test/e2e/webdriver/driver.js b/test/e2e/webdriver/driver.js index 1a10f7c0199d..42fa0f018f6d 100644 --- a/test/e2e/webdriver/driver.js +++ b/test/e2e/webdriver/driver.js @@ -1311,7 +1311,23 @@ class Driver { #getErrorFromEvent(event) { // Extract the values from the array - const values = event.args.map((a) => a.value); + const values = event.args.map((a) => { + // Handle snaps error type + if (a && a.preview && Array.isArray(a.preview.properties)) { + return a.preview.properties + .filter((prop) => prop.value !== 'Object') + .map((prop) => prop.value) + .join(', '); + } else if (a.description) { + // Handle RPC error type + return a.description; + } else if (a.value) { + // Handle generic error types + return a.value; + } + // Fallback for other error structures + return JSON.stringify(a, null, 2); + }); if (values[0]?.includes('%s')) { // The values are in the "printf" form of [message, ...substitutions] diff --git a/test/integration/data/integration-init-state.json b/test/integration/data/integration-init-state.json index a0ae3a8fb146..ed42111c00e1 100644 --- a/test/integration/data/integration-init-state.json +++ b/test/integration/data/integration-init-state.json @@ -784,7 +784,8 @@ "smartTransactionsOptInStatus": true, "petnamesEnabled": false, "showConfirmationAdvancedDetails": false, - "showMultiRpcModal": false + "showMultiRpcModal": false, + "tokenNetworkFilter": {} }, "preventPollingOnNetworkRestart": true, "previousAppVersion": "11.14.4", diff --git a/test/integration/notifications&auth/data/notification-state.ts b/test/integration/notifications&auth/data/notification-state.ts index c58bf707f521..61d74d161671 100644 --- a/test/integration/notifications&auth/data/notification-state.ts +++ b/test/integration/notifications&auth/data/notification-state.ts @@ -38,6 +38,8 @@ export const getMockedNotificationsState = () => { ...mockMetaMaskState, isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, isMetamaskNotificationsFeatureSeen: true, isNotificationServicesEnabled: true, isFeatureAnnouncementsEnabled: true, diff --git a/types/postcss-discard-font-face.d.ts b/types/postcss-discard-font-face.d.ts new file mode 100644 index 000000000000..83adf5c4a43c --- /dev/null +++ b/types/postcss-discard-font-face.d.ts @@ -0,0 +1,68 @@ +declare module 'postcss-discard-font-face' { + import { type Plugin as PostCssPlugin } from 'postcss'; + + /** + * For each font, return `false` to remove, or a new string if you would like + * to transform the *URL*. + * + * @example + * ```typescript + * (url: string, format: string) => { + * return !url.includes('.exe'); // remove if url ends with `.exe` + * } + * ``` + */ + type FilterFunction = (url: string, format: string) => boolean | string; + + /** + * Allowlist is an array of formats to *keep*. + * + * @example + * ```javascript + * ['ttf', 'svg'] // keep ttf and svg formats + * ``` + */ + type Allowlist = string[]; + + /** + * @example + * ```javascript + * { + * weight: [400], + * style: ['normal'] + * } + * ``` + */ + type Properties = Record; + + /** + * @example + * ```typescript + * const options = { + * font: { + * // keep `Arial` with `weight: 400` and `style: normal` + * Arial: { + * weight: [400], + * style: ["normal"] + * } + * } + * } + * ``` + */ + type Options = { + font: { + [fontName: string]: Properties; + }; + }; + + /** + * Discard font faces with PostCSS. + * + * @param filter - A filter function, allowlist, or options object + */ + function discardFontFace( + filter: Allowlist | FilterFunction | Options, + ): PostCssPlugin; + + export = discardFontFace; +} diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx index de771976e677..2925277c14bd 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/asset-list-control-bar.tsx @@ -1,6 +1,10 @@ -import React, { useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { getCurrentNetwork, getPreferences } from '../../../../../selectors'; +import React, { useEffect, useRef, useState, useContext, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + getCurrentNetwork, + getNetworkConfigurationsByChainId, + getPreferences, +} from '../../../../../selectors'; import { Box, ButtonBase, @@ -9,17 +13,22 @@ import { Popover, PopoverPosition, } from '../../../../component-library'; -import SortControl from '../sort-control'; +import SortControl, { SelectableListItem } from '../sort-control/sort-control'; import { BackgroundColor, - BorderColor, - BorderStyle, Display, JustifyContent, TextColor, + TextVariant, } from '../../../../../helpers/constants/design-system'; import ImportControl from '../import-control'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { MetaMetricsContext } from '../../../../../contexts/metametrics'; +import { TEST_CHAINS } from '../../../../../../shared/constants/network'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../../../shared/constants/metametrics'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../../../../app/scripts/lib/util'; @@ -28,6 +37,12 @@ import { ENVIRONMENT_TYPE_POPUP, } from '../../../../../../shared/constants/app'; import NetworkFilter from '../network-filter'; +import { + detectTokens, + setTokenNetworkFilter, + showImportTokensModal, +} from '../../../../../store/actions'; +import Tooltip from '../../../../ui/tooltip'; type AssetListControlBarProps = { showTokensLinks?: boolean; @@ -35,14 +50,54 @@ type AssetListControlBarProps = { const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const t = useI18nContext(); + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); const popoverRef = useRef(null); const currentNetwork = useSelector(getCurrentNetwork); + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); const [isTokenSortPopoverOpen, setIsTokenSortPopoverOpen] = useState(false); + const [isImportTokensPopoverOpen, setIsImportTokensPopoverOpen] = + useState(false); const [isNetworkFilterPopoverOpen, setIsNetworkFilterPopoverOpen] = useState(false); - const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; + const isTestNetwork = useMemo(() => { + return (TEST_CHAINS as string[]).includes(currentNetwork.chainId); + }, [currentNetwork.chainId, TEST_CHAINS]); + + const allOpts: Record = {}; + Object.keys(allNetworks).forEach((chainId) => { + allOpts[chainId] = true; + }); + + const allNetworksFilterShown = + Object.keys(tokenNetworkFilter).length !== Object.keys(allOpts).length; + + useEffect(() => { + if (isTestNetwork) { + const testnetFilter = { [currentNetwork.chainId]: true }; + dispatch(setTokenNetworkFilter(testnetFilter)); + } + }, [isTestNetwork, currentNetwork.chainId, dispatch]); + + // TODO: This useEffect should be a migration + // We need to set the default filter for all users to be all included networks, rather than defaulting to empty object + // This effect is to unblock and derisk in the short-term + useEffect(() => { + if (Object.keys(tokenNetworkFilter).length === 0) { + dispatch(setTokenNetworkFilter(allOpts)); + } + }, []); + + // When a network gets added/removed we want to make sure that we switch to the filtered list of the current network + // We only want to do this if the "Current Network" filter is selected + useEffect(() => { + if (Object.keys(tokenNetworkFilter).length === 1) { + dispatch(setTokenNetworkFilter({ [currentNetwork.chainId]: true })); + } + }, [Object.keys(allNetworks).length]); const windowType = getEnvironmentType(); const isFullScreen = @@ -51,47 +106,74 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { const toggleTokenSortPopover = () => { setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(false); setIsTokenSortPopoverOpen(!isTokenSortPopoverOpen); }; const toggleNetworkFilterPopover = () => { setIsTokenSortPopoverOpen(false); + setIsImportTokensPopoverOpen(false); setIsNetworkFilterPopoverOpen(!isNetworkFilterPopoverOpen); }; + const toggleImportTokensPopover = () => { + setIsTokenSortPopoverOpen(false); + setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(!isImportTokensPopoverOpen); + }; + const closePopover = () => { setIsTokenSortPopoverOpen(false); setIsNetworkFilterPopoverOpen(false); + setIsImportTokensPopoverOpen(false); + }; + + const handleImport = () => { + dispatch(showImportTokensModal()); + trackEvent({ + category: MetaMetricsEventCategory.Navigation, + event: MetaMetricsEventName.TokenImportButtonClicked, + properties: { + location: 'HOME', + }, + }); + closePopover(); + }; + + const handleRefresh = () => { + dispatch(detectTokens()); + closePopover(); }; return ( - {process.env.FILTER_TOKENS_TOGGLE && ( + {process.env.PORTFOLIO_VIEW && ( { )} - - {t('sortBy')} - + + + - + + { { > + + + + {t('importTokensCamelCase')} + + + {t('refreshList')} + + ); }; diff --git a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss index b6dfacce0082..1fee45c33a87 100644 --- a/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss +++ b/ui/components/app/assets/asset-list/asset-list-control-bar/index.scss @@ -1,12 +1,38 @@ .asset-list-control-bar { - padding-top: 8px; - padding-bottom: 8px; + padding-top: 4px; + padding-bottom: 4px; &__button { - // using percentage here to allow for full network name to show when full screen, but ellipsize on extension view max-width: 35%; } + &__network_control { + justify-content: space-between; + width: auto; + min-width: auto; + border-radius: 8px; + padding: 0 8px !important; + gap: 5px; + text-transform: lowercase; + + span::first-letter { + text-transform: uppercase; + } + } + + &__buttons { + display: flex; + justify-content: flex-end; + + & .asset-list-control-bar__button { + width: 32px; + min-width: 32px; + margin: 0; + border-radius: 8px; + padding: 0; + } + } + &__button:hover { background-color: var(--color-background-hover); } diff --git a/ui/components/app/assets/asset-list/asset-list.test.tsx b/ui/components/app/assets/asset-list/asset-list.test.tsx index 329c29a6108e..00a47df1c633 100644 --- a/ui/components/app/assets/asset-list/asset-list.test.tsx +++ b/ui/components/app/assets/asset-list/asset-list.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { screen, act, waitFor } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/jest'; -import configureStore, { MetaMaskReduxState } from '../../../../store/store'; +import { MetaMaskReduxState } from '../../../../store/store'; import mockState from '../../../../../test/data/mock-state.json'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import { useIsOriginalNativeTokenSymbol } from '../../../../hooks/useIsOriginalNativeTokenSymbol'; +import useMultiPolling from '../../../../hooks/useMultiPolling'; import { getTokenSymbol } from '../../../../store/actions'; import { getSelectedInternalAccountFromMockState } from '../../../../../test/jest/mocks'; import { mockNetworkState } from '../../../../../test/stub/networks'; @@ -64,9 +67,19 @@ jest.mock('../../../../hooks/useIsOriginalNativeTokenSymbol', () => { jest.mock('../../../../store/actions', () => { return { getTokenSymbol: jest.fn(), + setTokenNetworkFilter: jest.fn(() => ({ + type: 'TOKEN_NETWORK_FILTER', + })), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), }; }); +jest.mock('../../../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const mockSelectedInternalAccount = getSelectedInternalAccountFromMockState( mockState as unknown as MetaMaskReduxState, ); @@ -101,7 +114,7 @@ const render = (balance = ETH_BALANCE, chainId = CHAIN_IDS.MAINNET) => { }, }, }; - const store = configureStore(state); + const store = configureMockStore([thunk])(state); return renderWithProvider( undefined} showTokensLinks />, store, @@ -109,6 +122,22 @@ const render = (balance = ETH_BALANCE, chainId = CHAIN_IDS.MAINNET) => { }; describe('AssetList', () => { + (useMultiPolling as jest.Mock).mockClear(); + + // Mock implementation for useMultiPolling + (useMultiPolling as jest.Mock).mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem: string) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); (useIsOriginalNativeTokenSymbol as jest.Mock).mockReturnValue(true); (getTokenSymbol as jest.Mock).mockImplementation(async (address) => { @@ -124,13 +153,14 @@ describe('AssetList', () => { return null; }); - it('renders AssetList component and shows Refresh List text', async () => { + it('renders AssetList component and shows AssetList control bar', async () => { await act(async () => { render(); }); await waitFor(() => { - expect(screen.getByText('Refresh list')).toBeInTheDocument(); + expect(screen.getByTestId('sort-by-popover-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('import-token-button')).toBeInTheDocument(); }); }); }); diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 4cbe529e3df2..9ed6b718cbd8 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -13,8 +13,8 @@ import { getMultichainSelectedAccountCachedBalance, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getMultichainIsBitcoin, - ///: END:ONLY_INCLUDE_IF getMultichainSelectedAccountCachedBalanceIsZero, + ///: END:ONLY_INCLUDE_IF } from '../../../../selectors/multichain'; import { useCurrencyDisplay } from '../../../../hooks/useCurrencyDisplay'; import { MetaMetricsContext } from '../../../../contexts/metametrics'; @@ -23,11 +23,7 @@ import { MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; import DetectedToken from '../../detected-token/detected-token'; -import { - DetectedTokensBanner, - ImportTokenLink, - ReceiveModal, -} from '../../../multichain'; +import { DetectedTokensBanner, ReceiveModal } from '../../../multichain'; import { useI18nContext } from '../../../../hooks/useI18nContext'; import { FundingMethodModal } from '../../../multichain/funding-method-modal/funding-method-modal'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -51,7 +47,7 @@ export type TokenWithBalance = { }; export type AssetListProps = { - onClickAsset: (arg: string) => void; + onClickAsset: (chainId: string, address: string) => void; showTokensLinks?: boolean; }; @@ -88,11 +84,10 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { setShowReceiveModal(true); }; + ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const balanceIsZero = useSelector( getMultichainSelectedAccountCachedBalanceIsZero, ); - - ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const isBuyableChain = useSelector(getIsNativeTokenBuyable); const shouldShowBuy = isBuyableChain && balanceIsZero; const isBtc = useSelector(getMultichainIsBitcoin); @@ -113,11 +108,11 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { margin={4} /> )} - + } - onTokenClick={(tokenAddress: string) => { - onClickAsset(tokenAddress); + onTokenClick={(chainId: string, tokenAddress: string) => { + onClickAsset(chainId, tokenAddress); trackEvent({ event: MetaMetricsEventName.TokenScreenOpened, category: MetaMetricsEventCategory.Navigation, @@ -144,13 +139,6 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { ) : null ///: END:ONLY_INCLUDE_IF } - {shouldShowTokensLinks && ( - 0 && !balanceIsZero ? 0 : 2} - /> - )} {showDetectedTokens && ( )} diff --git a/ui/components/app/assets/asset-list/import-control/import-control.tsx b/ui/components/app/assets/asset-list/import-control/import-control.tsx index 37af8714e2c7..d3a9bfd9ccb7 100644 --- a/ui/components/app/assets/asset-list/import-control/import-control.tsx +++ b/ui/components/app/assets/asset-list/import-control/import-control.tsx @@ -1,5 +1,5 @@ -import React, { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React from 'react'; +import { useSelector } from 'react-redux'; import { ButtonBase, ButtonBaseSize, @@ -7,27 +7,20 @@ import { } from '../../../../component-library'; import { BackgroundColor, - BorderColor, - BorderStyle, TextColor, } from '../../../../../helpers/constants/design-system'; -import { showImportTokensModal } from '../../../../../store/actions'; -import { MetaMetricsContext } from '../../../../../contexts/metametrics'; -import { - MetaMetricsEventCategory, - MetaMetricsEventName, -} from '../../../../../../shared/constants/metametrics'; + import { getMultichainIsEvm } from '../../../../../selectors/multichain'; -import { useI18nContext } from '../../../../../hooks/useI18nContext'; type AssetListControlBarProps = { showTokensLinks?: boolean; + onClick?: () => void; }; -const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { - const dispatch = useDispatch(); - const trackEvent = useContext(MetaMetricsContext); - const t = useI18nContext(); +const AssetListControlBar = ({ + showTokensLinks, + onClick, +}: AssetListControlBarProps) => { const isEvm = useSelector(getMultichainIsEvm); // NOTE: Since we can parametrize it now, we keep the original behavior // for EVM assets @@ -39,24 +32,11 @@ const AssetListControlBar = ({ showTokensLinks }: AssetListControlBarProps) => { data-testid="import-token-button" disabled={!shouldShowTokensLinks} size={ButtonBaseSize.Sm} - startIconName={IconName.Add} + startIconName={IconName.MoreVertical} backgroundColor={BackgroundColor.backgroundDefault} - borderColor={BorderColor.borderMuted} - borderStyle={BorderStyle.solid} color={TextColor.textDefault} - onClick={() => { - dispatch(showImportTokensModal()); - trackEvent({ - category: MetaMetricsEventCategory.Navigation, - event: MetaMetricsEventName.TokenImportButtonClicked, - properties: { - location: 'HOME', - }, - }); - }} - > - {t('import')} - + onClick={onClick} + /> ); }; diff --git a/ui/components/app/assets/asset-list/native-token/native-token.tsx b/ui/components/app/assets/asset-list/native-token/native-token.tsx index e63a2902a552..b1e86479bb79 100644 --- a/ui/components/app/assets/asset-list/native-token/native-token.tsx +++ b/ui/components/app/assets/asset-list/native-token/native-token.tsx @@ -43,7 +43,8 @@ const NativeToken = ({ onClickAsset }: AssetListProps) => { return ( onClickAsset(nativeCurrency)} + chainId={chainId} + onClick={() => onClickAsset(chainId, nativeCurrency)} title={nativeCurrency} primary={string} tokenSymbol={symbol} diff --git a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx index cc2d0f38210e..8b9fc06b33e7 100644 --- a/ui/components/app/assets/asset-list/network-filter/network-filter.tsx +++ b/ui/components/app/assets/asset-list/network-filter/network-filter.tsx @@ -4,15 +4,14 @@ import { setTokenNetworkFilter } from '../../../../../store/actions'; import { getCurrentChainId, getCurrentNetwork, - getIsTestnet, getPreferences, getSelectedInternalAccount, getShouldHideZeroBalanceTokens, getNetworkConfigurationsByChainId, + getChainIdsToPoll, } from '../../../../../selectors'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { SelectableListItem } from '../sort-control/sort-control'; -import { useAccountTotalFiatBalance } from '../../../../../hooks/useAccountTotalFiatBalance'; import { Text } from '../../../../component-library/text/text'; import { Display, @@ -24,6 +23,8 @@ import { Box } from '../../../../component-library/box/box'; import { AvatarNetwork } from '../../../../component-library'; import UserPreferencedCurrencyDisplay from '../../../user-preferenced-currency-display'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../shared/constants/network'; +import { useGetFormattedTokensPerChain } from '../../../../../hooks/useGetFormattedTokensPerChain'; +import { useAccountTotalCrossChainFiatBalance } from '../../../../../hooks/useAccountTotalCrossChainFiatBalance'; type SortControlProps = { handleClose: () => void; @@ -36,15 +37,35 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { const selectedAccount = useSelector(getSelectedInternalAccount); const currentNetwork = useSelector(getCurrentNetwork); const allNetworks = useSelector(getNetworkConfigurationsByChainId); - const isTestnet = useSelector(getIsTestnet); - const { tokenNetworkFilter, showNativeTokenAsMainBalance } = - useSelector(getPreferences); + const { tokenNetworkFilter } = useSelector(getPreferences); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); - + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + true, // true to get formattedTokensWithBalancesPerChain for the current chain + allChainIDs, + ); const { totalFiatBalance: selectedAccountBalance } = - useAccountTotalFiatBalance(selectedAccount, shouldHideZeroBalanceTokens); + useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensWithBalancesPerChain, + ); + + const { formattedTokensWithBalancesPerChain: formattedTokensForAllNetworks } = + useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + false, // false to get the value for all networks + allChainIDs, + ); + const { totalFiatBalance: selectedAccountBalanceForAllNetworks } = + useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensForAllNetworks, + ); // TODO: fetch balances across networks // const multiNetworkAccountBalance = useMultichainAccountBalance() @@ -78,7 +99,15 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { color={TextColor.textDefault} > {/* TODO: Should query cross chain account balance */} - $1,000.00 + + @@ -120,16 +149,19 @@ const NetworkFilter = ({ handleClose }: SortControlProps) => { > {t('currentNetwork')} - + + + ; testId?: string; children: ReactNode; @@ -39,7 +39,7 @@ export const SelectableListItem = ({ diff --git a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js index 9a1062e4f177..2a9f3287346e 100644 --- a/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js +++ b/ui/components/app/assets/nfts/nfts-tab/nfts-tab.js @@ -39,6 +39,7 @@ import { } from '../../../../../../shared/constants/metametrics'; import { getCurrentLocale } from '../../../../../ducks/locale/locale'; import Spinner from '../../../../ui/spinner'; +import { endTrace, TraceName } from '../../../../../../shared/lib/trace'; export default function NftsTab() { const useNftDetection = useSelector(getUseNftDetection); @@ -93,6 +94,12 @@ export default function NftsTab() { currentLocale, ]); + useEffect(() => { + if (!nftsLoading && !nftsStillFetchingIndication) { + endTrace({ name: TraceName.AccountOverviewNftsTab }); + } + }, [nftsLoading, nftsStillFetchingIndication]); + if (!hasAnyNfts && nftsStillFetchingIndication) { return ( diff --git a/ui/components/app/assets/token-cell/token-cell.test.tsx b/ui/components/app/assets/token-cell/token-cell.test.tsx index 5cb4b30aea49..1968468dcac4 100644 --- a/ui/components/app/assets/token-cell/token-cell.test.tsx +++ b/ui/components/app/assets/token-cell/token-cell.test.tsx @@ -85,6 +85,8 @@ describe('Token Cell', () => { string: '5.000', currentCurrency: 'usd', image: '', + chainId: '0x1', + tokenFiatAmount: 5, onClick: jest.fn(), }; @@ -94,6 +96,8 @@ describe('Token Cell', () => { string: '5000000', currentCurrency: 'usd', image: '', + chainId: '0x1', + tokenFiatAmount: 5000000, onClick: jest.fn(), }; const useSelectorMock = useSelector; diff --git a/ui/components/app/assets/token-cell/token-cell.tsx b/ui/components/app/assets/token-cell/token-cell.tsx index 31bb388aa65b..81c237b441d9 100644 --- a/ui/components/app/assets/token-cell/token-cell.tsx +++ b/ui/components/app/assets/token-cell/token-cell.tsx @@ -11,15 +11,17 @@ type TokenCellProps = { address: string; symbol: string; string?: string; + chainId: string; image: string; privacyMode?: boolean; - onClick?: (arg: string) => void; + onClick?: (chainId: string, address: string) => void; }; export default function TokenCell({ address, image, symbol, + chainId, string, privacyMode = false, onClick, @@ -44,7 +46,8 @@ export default function TokenCell({ return ( onClick(address) : undefined} + chainId={chainId} + onClick={onClick ? () => onClick(chainId, address) : undefined} tokenSymbol={symbol} tokenImage={tokenImage} primary={`${primary || 0}`} diff --git a/ui/components/app/assets/token-list/token-list.tsx b/ui/components/app/assets/token-list/token-list.tsx index f0b17d686026..638c66610a80 100644 --- a/ui/components/app/assets/token-list/token-list.tsx +++ b/ui/components/app/assets/token-list/token-list.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useMemo } from 'react'; +import React, { ReactNode, useEffect, useMemo } from 'react'; import { shallowEqual, useSelector } from 'react-redux'; import TokenCell from '../token-cell'; import { useI18nContext } from '../../../../hooks/useI18nContext'; @@ -11,6 +11,7 @@ import { import { TokenWithBalance } from '../asset-list/asset-list'; import { sortAssets } from '../util/sort'; import { + getCurrentChainId, getPreferences, getSelectedAccount, getShouldHideZeroBalanceTokens, @@ -19,9 +20,10 @@ import { import { useAccountTotalFiatBalance } from '../../../../hooks/useAccountTotalFiatBalance'; import { getConversionRate } from '../../../../ducks/metamask/metamask'; import { useNativeTokenBalance } from '../asset-list/native-token/use-native-token-balance'; +import { endTrace, TraceName } from '../../../../../shared/lib/trace'; type TokenListProps = { - onTokenClick: (arg: string) => void; + onTokenClick: (chainId: string, address: string) => void; nativeToken: ReactNode; }; @@ -30,6 +32,7 @@ export default function TokenList({ nativeToken, }: TokenListProps) { const t = useI18nContext(); + const currentChainId = useSelector(getCurrentChainId); const { tokenSortConfig, tokenNetworkFilter, privacyMode } = useSelector(getPreferences); const selectedAccount = useSelector(getSelectedAccount); @@ -66,6 +69,12 @@ export default function TokenList({ contractExchangeRates, ]); + useEffect(() => { + if (!loading) { + endTrace({ name: TraceName.AccountOverviewAssetListTab }); + } + }, [loading]); + return loading ? ( - Expandable Value + Expandable Value
- {children} + {children} + +
+ ); + } + renderRecents() { const { t } = this.context; const { isShowingAllRecent } = this.state; @@ -45,15 +62,40 @@ export default class ContactList extends PureComponent { } renderAddressBook() { - const unsortedContactsByLetter = this.props - .searchForContacts() - .reduce((obj, contact) => { + const { + addressBook, + internalAccounts, + searchForContacts, + selectRecipient, + selectedAddress, + } = this.props; + + const duplicateContactMap = buildDuplicateContactMap( + addressBook, + internalAccounts, + ); + + const unsortedContactsByLetter = searchForContacts().reduce( + (obj, contact) => { const firstLetter = contact.name[0].toUpperCase(); + + const isDuplicate = + (duplicateContactMap.get(contact.name.trim().toLowerCase()) ?? []) + .length > 1; + return { ...obj, - [firstLetter]: [...(obj[firstLetter] || []), contact], + [firstLetter]: [ + ...(obj[firstLetter] || []), + { + ...contact, + isDuplicate, + }, + ], }; - }, {}); + }, + {}, + ); const letters = Object.keys(unsortedContactsByLetter).sort(); @@ -71,8 +113,8 @@ export default class ContactList extends PureComponent { key={`${letter}-contact-group`} label={letter} items={groupItems} - onSelect={this.props.selectRecipient} - selectedAddress={this.props.selectedAddress} + onSelect={selectRecipient} + selectedAddress={selectedAddress} /> )); } @@ -95,11 +137,16 @@ export default class ContactList extends PureComponent { searchForRecents, searchForContacts, searchForMyAccounts, + addressBook, + internalAccounts, } = this.props; return (
{children || null} + {hasDuplicateContacts(addressBook, internalAccounts) + ? this.renderDuplicateContactWarning() + : null} {searchForRecents ? this.renderRecents() : null} {searchForContacts ? this.renderAddressBook() : null} {searchForMyAccounts ? this.renderMyAccounts() : null} diff --git a/ui/components/app/contact-list/contact-list.test.js b/ui/components/app/contact-list/contact-list.test.js index 6d0a990cb08c..1beecc17e0fa 100644 --- a/ui/components/app/contact-list/contact-list.test.js +++ b/ui/components/app/contact-list/contact-list.test.js @@ -1,6 +1,8 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { renderWithProvider } from '../../../../test/jest/rendering'; +import { MOCK_ADDRESS_BOOK } from '../../../../test/data/mock-data'; +import { createMockInternalAccount } from '../../../../test/jest/mocks'; import ContactList from '.'; describe('Contact List', () => { @@ -8,6 +10,48 @@ describe('Contact List', () => { metamask: {}, }); + const mockInternalAccounts = [createMockInternalAccount()]; + + it('displays the warning banner when multiple contacts have the same name', () => { + const mockAddressBook = [...MOCK_ADDRESS_BOOK, MOCK_ADDRESS_BOOK[0]]; // Adding duplicate contact + + const { getByText } = renderWithProvider( + , + store, + ); + + const duplicateContactBanner = getByText('You have duplicate contacts'); + + expect(duplicateContactBanner).toBeVisible(); + }); + + it('displays the warning banner when contact has same name as an existing account', () => { + const mockContactWithAccountName = { + address: '0x2f318C334780961FB129D2a6c30D0763d9a5C970', + chainId: '0x1', + isEns: false, + memo: '', + name: mockInternalAccounts[0].metadata.name, + }; + + const mockAddressBook = [...MOCK_ADDRESS_BOOK, mockContactWithAccountName]; + + const { getByText } = renderWithProvider( + , + store, + ); + + const duplicateContactBanner = getByText('You have duplicate contacts'); + + expect(duplicateContactBanner).toBeVisible(); + }); + describe('given searchForContacts', () => { const selectRecipient = () => null; const selectedAddress = null; @@ -37,6 +81,8 @@ describe('Contact List', () => { searchForContacts={() => contacts} selectRecipient={selectRecipient} selectedAddress={selectedAddress} + addressBook={MOCK_ADDRESS_BOOK} + internalAccounts={mockInternalAccounts} />, store, ); diff --git a/ui/components/app/contact-list/recipient-group/recipient-group.component.js b/ui/components/app/contact-list/recipient-group/recipient-group.component.js index 6bb0b4c30dd6..0788d29aaecd 100644 --- a/ui/components/app/contact-list/recipient-group/recipient-group.component.js +++ b/ui/components/app/contact-list/recipient-group/recipient-group.component.js @@ -7,12 +7,13 @@ export default function RecipientGroup({ items, onSelect }) { return null; } - return items.map(({ address, name }) => ( + return items.map(({ address, name, isDuplicate }) => ( onSelect(address, name)} key={address} + isDuplicate={isDuplicate} /> )); } diff --git a/ui/components/app/contact-list/utils.ts b/ui/components/app/contact-list/utils.ts new file mode 100644 index 000000000000..4254988e4af6 --- /dev/null +++ b/ui/components/app/contact-list/utils.ts @@ -0,0 +1,61 @@ +import { AddressBookEntry } from '@metamask/address-book-controller'; +import { InternalAccount } from '@metamask/keyring-api'; + +export const buildDuplicateContactMap = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], +) => { + const contactMap = new Map( + internalAccounts.map((account) => [ + account.metadata.name.trim().toLowerCase(), + [`account-id-${account.id}`], + ]), + ); + + addressBook.forEach((entry) => { + const { name, address } = entry; + + const sanitizedName = name.trim().toLowerCase(); + + const currentArray = contactMap.get(sanitizedName) ?? []; + currentArray.push(address); + + contactMap.set(sanitizedName, currentArray); + }); + + return contactMap; +}; + +export const hasDuplicateContacts = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], +) => { + const uniqueContactNames = Array.from( + new Set(addressBook.map(({ name }) => name.toLowerCase().trim())), + ); + + const hasAccountNameCollision = internalAccounts.some((account) => + uniqueContactNames.includes(account.metadata.name.toLowerCase().trim()), + ); + + return ( + uniqueContactNames.length !== addressBook.length || hasAccountNameCollision + ); +}; + +export const isDuplicateContact = ( + addressBook: AddressBookEntry[], + internalAccounts: InternalAccount[], + newName: string, +) => { + const nameExistsInAddressBook = addressBook.some( + ({ name }) => name.toLowerCase().trim() === newName.toLowerCase().trim(), + ); + + const nameExistsInAccountList = internalAccounts.some( + ({ metadata }) => + metadata.name.toLowerCase().trim() === newName.toLowerCase().trim(), + ); + + return nameExistsInAddressBook || nameExistsInAccountList; +}; diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js index 2556cc767623..0229173050d8 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.js @@ -18,6 +18,7 @@ import Popover from '../../../ui/popover'; import Box from '../../../ui/box'; import Button from '../../../ui/button'; import DetectedTokenDetails from '../detected-token-details/detected-token-details'; +import { trace, endTrace, TraceName } from '../../../../../shared/lib/trace'; const DetectedTokenSelectionPopover = ({ tokensListDetected, @@ -64,7 +65,11 @@ const DetectedTokenSelectionPopover = ({
`; +exports[`Name renders address with long saved name 1`] = ` +
+
+
+
+
+
+ + + + + +
+
+
+

+ Very long and l... +

+
+
+
+`; + exports[`Name renders address with no saved name 1`] = `
{ expect(container).toMatchSnapshot(); }); + it('renders address with long saved name', () => { + useDisplayNameMock.mockReturnValue({ + name: "Very long and length saved name that doesn't seem to end, really.", + hasPetname: true, + }); + + const { container } = renderWithProvider( + , + store, + ); + + expect(container).toMatchSnapshot(); + }); + it('renders address with image', () => { useDisplayNameMock.mockReturnValue({ name: SAVED_NAME_MOCK, diff --git a/ui/components/app/name/name.tsx b/ui/components/app/name/name.tsx index 2097d21faf07..75f2a2f79c15 100644 --- a/ui/components/app/name/name.tsx +++ b/ui/components/app/name/name.tsx @@ -9,7 +9,7 @@ import { NameType } from '@metamask/name-controller'; import classnames from 'classnames'; import { toChecksumAddress } from 'ethereumjs-util'; import { Box, Icon, IconName, IconSize, Text } from '../../component-library'; -import { shortenAddress } from '../../../helpers/utils/util'; +import { shortenAddress, shortenString } from '../../../helpers/utils/util'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { MetaMetricsEventCategory, @@ -103,6 +103,12 @@ const Name = memo( }, [setModalOpen]); const formattedValue = formatValue(value, type); + const formattedName = shortenString(name || undefined, { + truncatedCharLimit: 15, + truncatedStartChars: 15, + truncatedEndChars: 0, + skipCharacterInEnd: true, + }); const hasDisplayName = Boolean(name); return ( @@ -135,7 +141,7 @@ const Name = memo( )} {hasDisplayName ? ( - {name} + {formattedName} ) : ( diff --git a/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx b/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx index b7a0468b5315..15e33ee201c8 100644 --- a/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx +++ b/ui/components/app/snaps/snap-ui-card/snap-ui-card.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { Display, FlexDirection, @@ -13,7 +13,7 @@ import { SnapUIImage } from '../snap-ui-image'; export type SnapUICardProps = { image?: string | undefined; - title: string; + title: string | ReactNode; description?: string | undefined; value: string; extra?: string | undefined; diff --git a/ui/components/app/snaps/snap-ui-renderer/components/card.ts b/ui/components/app/snaps/snap-ui-renderer/components/card.ts index 64c2b1c12a57..932d1e7a532e 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/card.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/card.ts @@ -1,13 +1,34 @@ import { CardElement } from '@metamask/snaps-sdk/jsx'; +import { mapToTemplate } from '../utils'; import { UIComponentFactory } from './types'; -export const card: UIComponentFactory = ({ element }) => ({ - element: 'SnapUICard', - props: { - image: element.props.image, - title: element.props.title, - description: element.props.description, - value: element.props.value, - extra: element.props.extra, - }, -}); +export const card: UIComponentFactory = ({ + element, + ...params +}) => { + if (typeof element.props.title !== 'string') { + return { + element: 'SnapUICard', + props: { + image: element.props.image, + description: element.props.description, + value: element.props.value, + extra: element.props.extra, + }, + propComponents: { + title: mapToTemplate({ element: element.props.title, ...params }), + }, + }; + } + + return { + element: 'SnapUICard', + props: { + image: element.props.image, + title: element.props.title, + description: element.props.description, + value: element.props.value, + extra: element.props.extra, + }, + }; +}; diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index 860571fbe0c5..2fd7d0bc6d1a 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -4,6 +4,7 @@ import React, { useCallback, Fragment, useContext, + useEffect, } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; @@ -53,6 +54,7 @@ import { getMultichainAccountUrl } from '../../../helpers/utils/multichain/block import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import { getMultichainNetwork } from '../../../selectors/multichain'; +import { endTrace, TraceName } from '../../../../shared/lib/trace'; const PAGE_INCREMENT = 10; @@ -258,6 +260,11 @@ export default function TransactionList({ // Check if the current account is a bitcoin account const isBitcoinAccount = useSelector(isSelectedInternalAccountBtc); const trackEvent = useContext(MetaMetricsContext); + + useEffect(() => { + endTrace({ name: TraceName.AccountOverviewActivityTab }); + }, []); + const multichainNetwork = useMultichainSelector( getMultichainNetwork, selectedAccount, diff --git a/ui/components/app/wallet-overview/__snapshots__/aggregated-percentage-overview-cross-chains.test.tsx.snap b/ui/components/app/wallet-overview/__snapshots__/aggregated-percentage-overview-cross-chains.test.tsx.snap new file mode 100644 index 000000000000..c0c5129a16a4 --- /dev/null +++ b/ui/components/app/wallet-overview/__snapshots__/aggregated-percentage-overview-cross-chains.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AggregatedPercentageOverviewCrossChains render renders correctly 1`] = ` +
+
+

+ +$0.22 +

+

+ (+0.08%) +

+
+
+`; diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx new file mode 100644 index 000000000000..1a335de9c14b --- /dev/null +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.test.tsx @@ -0,0 +1,588 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getIntlLocale } from '../../../ducks/locale/locale'; +import { + getCurrentCurrency, + getSelectedAccount, + getShouldHideZeroBalanceTokens, + getPreferences, + getMarketData, + getNetworkConfigurationsByChainId, + getAllTokens, + getChainIdsToPoll, +} from '../../../selectors'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; +import { AggregatedPercentageOverviewCrossChains } from './aggregated-percentage-overview-cross-chains'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +const mockUseGetFormattedTokensPerChain = jest.fn().mockReturnValue({ + formattedTokensWithBalancesPerChain: {}, +}); +jest.mock('../../../hooks/useGetFormattedTokensPerChain', () => ({ + useGetFormattedTokensPerChain: () => mockUseGetFormattedTokensPerChain(), +})); + +jest.mock('../../../ducks/locale/locale', () => ({ + getIntlLocale: jest.fn(), +})); + +jest.mock('../../../selectors', () => ({ + getCurrentCurrency: jest.fn(), + getSelectedAccount: jest.fn(), + getPreferences: jest.fn(), + getShouldHideZeroBalanceTokens: jest.fn(), + getMarketData: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getAllTokens: jest.fn(), + getChainIdsToPoll: jest.fn(), +})); + +jest.mock('../../../hooks/useAccountTotalCrossChainFiatBalance', () => ({ + useAccountTotalCrossChainFiatBalance: jest.fn(), +})); + +const mockGetIntlLocale = getIntlLocale as unknown as jest.Mock; +const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; +const mockGetPreferences = getPreferences as jest.Mock; +const mockGetSelectedAccount = getSelectedAccount as unknown as jest.Mock; +const mockGetShouldHideZeroBalanceTokens = + getShouldHideZeroBalanceTokens as jest.Mock; + +const mockGetMarketData = getMarketData as jest.Mock; +const mockGetChainIdsToPoll = getChainIdsToPoll as unknown as jest.Mock; +const mockGetNetworkConfigurationsByChainId = + getNetworkConfigurationsByChainId as unknown as jest.Mock; +const mockGetAllTokens = getAllTokens as jest.Mock; + +const allTokens = { + '0x1': { + '0x2990079bcdee240329a520d2444386fc119da21a': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'Crypto.com', + 'CoinGecko', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 6, + symbol: 'USDC', + }, + ], + }, + '0xe708': { + '0x2990079bcdee240329a520d2444386fc119da21a': [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + aggregators: ['LineaTeam', 'CoinGecko', 'Lifi', 'Rubic', 'Xswap'], + decimals: 18, + symbol: 'DAI', + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + aggregators: [ + 'LineaTeam', + 'CoinGecko', + 'Lifi', + 'Squid', + 'Rubic', + 'Xswap', + ], + decimals: 6, + symbol: 'USDT', + }, + ], + }, +}; +const networkConfigsByChainId = { + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xaa36a7': { + blockExplorerUrls: ['https://sepolia.etherscan.io'], + chainId: '0xaa36a7', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Sepolia', + nativeCurrency: 'SepoliaETH', + rpcEndpoints: [ + { + networkClientId: 'sepolia', + type: 'infura', + url: 'https://sepolia.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe705': { + blockExplorerUrls: ['https://sepolia.lineascan.build'], + chainId: '0xe705', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea Sepolia', + nativeCurrency: 'LineaETH', + rpcEndpoints: [ + { + networkClientId: 'linea-sepolia', + type: 'infura', + url: 'https://linea-sepolia.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe708': { + blockExplorerUrls: ['https://lineascan.build'], + chainId: '0xe708', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, +}; +const selectedAccountMock = { + id: 'd51c0116-de36-4e77-b35b-408d4ea82d01', + address: '0x2990079bcdee240329a520d2444386fc119da21a', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + type: 'eip155:eoa', + metadata: { + name: 'Account 2', + importTime: 1725467263902, + lastSelected: 1725467263905, + keyring: { + type: 'Simple Key Pair', + }, + }, + balance: '0x0f7e2a03e67666', +}; + +const crossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: -0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: -0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: -0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: -0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: -0.2013707959252836, + }, + }, +}; + +const negativeCrossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: -0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: -0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: -0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: -0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: -0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: -0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: -0.2013707959252836, + }, + }, +}; +const positiveCrossChainMarketDataMock = { + '0x1': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + currency: 'ETH', + id: 'dai', + price: 0.00031298237681361845, + pricePercentChange1d: 0.19413664311573345, + }, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': { + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + currency: 'ETH', + id: 'usd-coin', + price: 0.00031298237681361845, + pricePercentChange1d: 0.08092791615953396, + }, + '0xdAC17F958D2ee523a2206206994597C13D831ec7': { + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + currency: 'ETH', + id: 'tether', + price: 0.00031329535919043206, + pricePercentChange1d: 0.09790827980452445, + }, + }, + '0xaa36a7': {}, + '0xe705': {}, + '0xe708': { + '0x0000000000000000000000000000000000000000': { + tokenAddress: '0x0000000000000000000000000000000000000000', + currency: 'ETH', + id: 'ethereum', + price: 0.9999974728621198, + pricePercentChange1d: 0.8551361112650235, + }, + '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5': { + tokenAddress: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + currency: 'ETH', + id: 'bridged-dai-stablecoin-linea', + price: 0.00031298237681361845, + pricePercentChange1d: 0.22242916875537241, + }, + '0xA219439258ca9da29E9Cc4cE5596924745e12B93': { + tokenAddress: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + currency: 'ETH', + id: 'bridged-tether-linea', + price: 0.0003136083415672457, + pricePercentChange1d: 0.2013707959252836, + }, + }, +}; +describe('AggregatedPercentageOverviewCrossChains', () => { + beforeEach(() => { + mockGetIntlLocale.mockReturnValue('en-US'); + mockGetCurrentCurrency.mockReturnValue('USD'); + mockGetPreferences.mockReturnValue({ privacyMode: false }); + mockGetSelectedAccount.mockReturnValue(selectedAccountMock); + mockGetShouldHideZeroBalanceTokens.mockReturnValue(false); + + mockGetMarketData.mockReturnValue(crossChainMarketDataMock); + mockGetChainIdsToPoll.mockReturnValue(['0x1']); + mockGetNetworkConfigurationsByChainId.mockReturnValue( + networkConfigsByChainId, + ); + mockGetAllTokens.mockReturnValue(allTokens); + + jest.clearAllMocks(); + }); + + describe('render', () => { + it('renders correctly', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + it('should display zero percentage and amount if balance is zero across chains', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [], + tokenFiatBalances: [], + nativeFiatValue: '0', + }, + { + chainId: '0xe708', + tokensWithBalances: [], + tokenFiatBalances: [], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 0, + }); + + render(); + const percentageElement = screen.getByText('(+0.00%)'); + const numberElement = screen.getByText('+$0.00'); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display negative aggregated amount and percentage change with all negative market data cross chains', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + mockGetMarketData.mockReturnValue(negativeCrossChainMarketDataMock); + const expectedAmountChange = '-$0.97'; + const expectedPercentageChange = '(-0.33%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display positive aggregated amount and percentage change with all positive market data', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + mockGetMarketData.mockReturnValue(positiveCrossChainMarketDataMock); + const expectedAmountChange = '+$0.96'; + const expectedPercentageChange = '(+0.33%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); + + it('should display correct aggregated amount and percentage change with positive and negative market data', () => { + (useAccountTotalCrossChainFiatBalance as jest.Mock).mockReturnValue({ + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + }, + ], + tokenFiatBalances: ['70'], + nativeFiatValue: '69.96', + }, + { + chainId: '0xe708', + tokensWithBalances: [ + { + address: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + symbol: 'DAI', + decimals: 18, + }, + { + address: '0xA219439258ca9da29E9Cc4cE5596924745e12B93', + symbol: 'USDT', + decimals: 6, + }, + ], + tokenFiatBalances: ['50', '100'], + nativeFiatValue: '0', + }, + ], + totalFiatBalance: 289.96, + }); + const expectedAmountChange = '+$0.22'; + const expectedPercentageChange = '(+0.08%)'; + render(); + const percentageElement = screen.getByText(expectedPercentageChange); + const numberElement = screen.getByText(expectedAmountChange); + expect(percentageElement).toBeInTheDocument(); + expect(numberElement).toBeInTheDocument(); + }); +}); diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx new file mode 100644 index 000000000000..fe3698e2fc2f --- /dev/null +++ b/ui/components/app/wallet-overview/aggregated-percentage-overview-cross-chains.tsx @@ -0,0 +1,187 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { zeroAddress, toChecksumAddress } from 'ethereumjs-util'; +import { + getCurrentCurrency, + getSelectedAccount, + getShouldHideZeroBalanceTokens, + getPreferences, + getMarketData, + getChainIdsToPoll, +} from '../../../selectors'; + +// TODO: Remove restricted import +// eslint-disable-next-line import/no-restricted-paths +import { formatValue, isValidAmount } from '../../../../app/scripts/lib/util'; +import { getIntlLocale } from '../../../ducks/locale/locale'; +import { + Display, + TextColor, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { Box, SensitiveText } from '../../component-library'; +import { getCalculatedTokenAmount1dAgo } from '../../../helpers/utils/util'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { TokenWithBalance } from '../assets/asset-list/asset-list'; + +export const AggregatedPercentageOverviewCrossChains = () => { + const locale = useSelector(getIntlLocale); + const fiatCurrency = useSelector(getCurrentCurrency); + const { privacyMode } = useSelector(getPreferences); + const selectedAccount = useSelector(getSelectedAccount); + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + const crossChainMarketData = useSelector(getMarketData); + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + selectedAccount, + shouldHideZeroBalanceTokens, + false, + allChainIDs, + ); + const { + totalFiatBalance: totalFiatCrossChains, + tokenFiatBalancesCrossChains, + } = useAccountTotalCrossChainFiatBalance( + selectedAccount, + formattedTokensWithBalancesPerChain, + ); + + const getPerChainTotalFiat1dAgo = ( + chainId: string, + tokenFiatBalances: (string | undefined)[], + tokensWithBalances: TokenWithBalance[], + ) => { + const totalPerChain1dAgoERC20 = tokensWithBalances.reduce( + (total1dAgo: number, item: { address: string }, idx: number) => { + const found = + crossChainMarketData?.[chainId]?.[toChecksumAddress(item.address)]; + + const tokenFiat1dAgo = getCalculatedTokenAmount1dAgo( + tokenFiatBalances[idx], + found?.pricePercentChange1d, + ); + return total1dAgo + Number(tokenFiat1dAgo); + }, + 0, + ); + + return totalPerChain1dAgoERC20; + }; + + const totalFiat1dAgoCrossChains = useMemo(() => { + return tokenFiatBalancesCrossChains.reduce( + ( + total1dAgoCrossChains: number, + item: { + chainId: string; + nativeFiatValue: string; + tokenFiatBalances: (string | undefined)[]; + tokensWithBalances: TokenWithBalance[]; + }, + ) => { + const perChainERC20Total = getPerChainTotalFiat1dAgo( + item.chainId, + item.tokenFiatBalances, + item.tokensWithBalances, + ); + const nativePricePercentChange1d = + crossChainMarketData?.[item.chainId]?.[zeroAddress()] + ?.pricePercentChange1d; + + const nativeFiat1dAgo = getCalculatedTokenAmount1dAgo( + item.nativeFiatValue, + nativePricePercentChange1d, + ); + return ( + total1dAgoCrossChains + perChainERC20Total + Number(nativeFiat1dAgo) + ); + }, + 0, + ); // Initial total1dAgo is 0 + }, [tokenFiatBalancesCrossChains, crossChainMarketData]); + + const totalCrossChainBalance: number = Number(totalFiatCrossChains); + const crossChainTotalBalance1dAgo = totalFiat1dAgoCrossChains; + + const amountChangeCrossChains = + totalCrossChainBalance - crossChainTotalBalance1dAgo; + const percentageChangeCrossChains = + (amountChangeCrossChains / crossChainTotalBalance1dAgo) * 100 || 0; + + const formattedPercentChangeCrossChains = formatValue( + amountChangeCrossChains === 0 ? 0 : percentageChangeCrossChains, + true, + ); + + let formattedAmountChangeCrossChains = ''; + if (isValidAmount(amountChangeCrossChains)) { + formattedAmountChangeCrossChains = + (amountChangeCrossChains as number) >= 0 ? '+' : ''; + + const options = { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 2, + } as const; + + try { + // For currencies compliant with ISO 4217 Standard + formattedAmountChangeCrossChains += `${Intl.NumberFormat(locale, { + ...options, + style: 'currency', + currency: fiatCurrency, + }).format(amountChangeCrossChains as number)} `; + } catch { + // Non-standard Currency Codes + formattedAmountChangeCrossChains += `${Intl.NumberFormat(locale, { + ...options, + minimumFractionDigits: 2, + style: 'decimal', + }).format(amountChangeCrossChains as number)} `; + } + } + + let color = TextColor.textDefault; + + if (!privacyMode && isValidAmount(amountChangeCrossChains)) { + if ((amountChangeCrossChains as number) === 0) { + color = TextColor.textDefault; + } else if ((amountChangeCrossChains as number) > 0) { + color = TextColor.successDefault; + } else { + color = TextColor.errorDefault; + } + } else { + color = TextColor.textAlternative; + } + + return ( + + + {formattedAmountChangeCrossChains} + + + {formattedPercentChangeCrossChains} + + + ); +}; diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index ffaed244e958..93c9e09ff0fd 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -11,14 +11,35 @@ import { MultichainNetworks } from '../../../../shared/constants/multichain/netw import { RampsMetaMaskEntry } from '../../../hooks/ramps/useRamps/useRamps'; import { defaultBuyableChains } from '../../../ducks/ramps/constants'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; import BtcOverview from './btc-overview'; +// We need to mock `dispatch` since we use it for `setDefaultHomeActiveTabName`. +const mockDispatch = jest.fn().mockReturnValue(() => jest.fn()); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +jest.mock('../../../store/actions', () => ({ + handleSnapRequest: jest.fn(), + sendMultichainTransaction: jest.fn(), + setDefaultHomeActiveTabName: jest.fn(), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + const PORTOFOLIO_URL = 'https://portfolio.test'; const BTC_OVERVIEW_BUY = 'coin-overview-buy'; const BTC_OVERVIEW_BRIDGE = 'coin-overview-bridge'; const BTC_OVERVIEW_RECEIVE = 'coin-overview-receive'; const BTC_OVERVIEW_SWAP = 'token-overview-button-swap'; +const BTC_OVERVIEW_SEND = 'coin-overview-send'; const BTC_OVERVIEW_PRIMARY_CURRENCY = 'coin-overview__primary-currency'; const mockMetaMetricsId = 'deadbeef'; @@ -129,6 +150,7 @@ describe('BtcOverview', () => { // The balances won't be available preferences: { showNativeTokenAsMainBalance: false, + tokenNetworkFilter: {}, }, }, }), @@ -228,6 +250,39 @@ describe('BtcOverview', () => { }); }); + it('sends an event when clicking the Buy button', () => { + const storeWithBtcBuyable = getStore({ + ramps: { + buyableChains: mockBuyableChainsWithBtc, + }, + }); + + const mockTrackEvent = jest.fn(); + const { queryByTestId } = renderWithProvider( + + + , + storeWithBtcBuyable, + ); + + const buyButton = queryByTestId(BTC_OVERVIEW_BUY); + expect(buyButton).toBeInTheDocument(); + expect(buyButton).not.toBeDisabled(); + fireEvent.click(buyButton as HTMLElement); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: MetaMetricsEventName.NavBuyButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockNonEvmAccount.type, + chain_id: MultichainNetworks.BITCOIN, + location: 'Home', + snap_id: mockNonEvmAccount.metadata.snap.id, + text: 'Buy', + }, + }); + }); + it('always show the Receive button', () => { const { queryByTestId } = renderWithProvider(, getStore()); const receiveButton = queryByTestId(BTC_OVERVIEW_RECEIVE); @@ -263,4 +318,42 @@ describe('BtcOverview', () => { expect(buyButton).toBeInTheDocument(); expect(buyButton).toBeDisabled(); }); + + it('always show the Send button', () => { + const { queryByTestId } = renderWithProvider(, getStore()); + const sendButton = queryByTestId(BTC_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + }); + + it('sends an event when clicking the Send button', () => { + const mockTrackEvent = jest.fn(); + const { queryByTestId } = renderWithProvider( + + + , + getStore(), + ); + + const sendButton = queryByTestId(BTC_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + fireEvent.click(sendButton as HTMLElement); + + expect(mockTrackEvent).toHaveBeenCalledWith( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockNonEvmAccount.type, + chain_id: MultichainNetworks.BITCOIN, + location: 'Home', + snap_id: mockNonEvmAccount.metadata.snap.id, + text: 'Send', + token_symbol: 'BTC', + }, + }, + expect.any(Object), + ); + }); }); diff --git a/ui/components/app/wallet-overview/btc-overview.tsx b/ui/components/app/wallet-overview/btc-overview.tsx index 2ddaefd92f58..fb315d3ab3b0 100644 --- a/ui/components/app/wallet-overview/btc-overview.tsx +++ b/ui/components/app/wallet-overview/btc-overview.tsx @@ -9,9 +9,9 @@ import { } from '../../../selectors/multichain'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { getIsBitcoinBuyable } from '../../../ducks/ramps'; -import { getSelectedInternalAccount } from '../../../selectors'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; ///: END:ONLY_INCLUDE_IF +import { getSelectedInternalAccount } from '../../../selectors'; import { CoinOverview } from './coin-overview'; type BtcOverviewProps = { @@ -21,17 +21,18 @@ type BtcOverviewProps = { const BtcOverview = ({ className }: BtcOverviewProps) => { const { chainId } = useSelector(getMultichainProviderConfig); const balance = useSelector(getMultichainSelectedAccountCachedBalance); + const account = useSelector(getSelectedInternalAccount); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - const selectedAccount = useSelector(getSelectedInternalAccount); const isBtcMainnetAccount = useMultichainSelector( getMultichainIsMainnet, - selectedAccount, + account, ); const isBtcBuyable = useSelector(getIsBitcoinBuyable); ///: END:ONLY_INCLUDE_IF return ( { +}: CoinButtonsProps) => { const t = useContext(I18nContext); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); const [showReceiveModal, setShowReceiveModal] = useState(false); - const account = useSelector(getSelectedAccount); const { address: selectedAddress } = account; const history = useHistory(); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + const currentActivityTabName = useSelector( + // @ts-expect-error TODO: fix state type + (state) => state.metamask.defaultHomeActiveTabName, + ); + ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) const location = useLocation(); const keyring = useSelector(getCurrentKeyring); const usingHardwareWallet = isHardwareKeyring(keyring?.type); ///: END:ONLY_INCLUDE_IF + // Initially, those events were using a "ETH" as `token_symbol`, so we keep this behavior + // for EVM, no matter the currently selected native token (e.g. SepoliaETH if you are on Sepolia + // network). + const isEvm = useMultichainSelector(getMultichainIsEvm, account); + const multichainNativeToken = useMultichainSelector( + getMultichainNativeCurrency, + account, + ); + const nativeToken = isEvm ? 'ETH' : multichainNativeToken; + const isExternalServicesEnabled = useSelector(getUseExternalServices); const buttonTooltips = { @@ -180,6 +203,23 @@ const CoinButtons = ({ }; ///: END:ONLY_INCLUDE_IF + const getSnapAccountMetaMetricsPropertiesIfAny = ( + internalAccount: InternalAccount, + ): { snap_id?: string } => { + // Some accounts might be Snap accounts, in this case we add some extra properties + // to the metrics: + const snapId = internalAccount.metadata.snap?.id; + if (snapId) { + return { + snap_id: snapId, + }; + } + + // If the account is not a Snap account or that we could not get the Snap ID for + // some reason, we don't add any extra property. + return {}; + }; + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const mmiPortfolioEnabled = useSelector(getMmiPortfolioEnabled); const mmiPortfolioUrl = useSelector(getMmiPortfolioUrl); @@ -276,34 +316,42 @@ const CoinButtons = ({ ///: END:ONLY_INCLUDE_IF const handleSendOnClick = useCallback(async () => { + trackEvent( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: account.type, + token_symbol: nativeToken, + location: 'Home', + text: 'Send', + chain_id: chainId, + ...getSnapAccountMetaMetricsPropertiesIfAny(account), + }, + }, + { excludeMetaMetricsId: false }, + ); switch (account.type) { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) case BtcAccountType.P2wpkh: { - await sendMultichainTransaction( - BITCOIN_WALLET_SNAP_ID, - account.id, - chainId as CaipChainId, - ); + try { + // FIXME: We switch the tab before starting the send flow (we + // faced some inconsistencies when changing it after). + await dispatch(setDefaultHomeActiveTabName('activity')); + await sendMultichainTransaction( + BITCOIN_WALLET_SNAP_ID, + account.id, + chainId as CaipChainId, + ); + } catch { + // Restore the previous tab in case of any error (see FIXME comment above). + await dispatch(setDefaultHomeActiveTabName(currentActivityTabName)); + } - // We automatically switch to the activity tab once the transaction has been sent. - dispatch(setDefaultHomeActiveTabName('activity')); break; } ///: END:ONLY_INCLUDE_IF default: { - trackEvent( - { - event: MetaMetricsEventName.NavSendButtonClicked, - category: MetaMetricsEventCategory.Navigation, - properties: { - token_symbol: 'ETH', - location: 'Home', - text: 'Send', - chain_id: chainId, - }, - }, - { excludeMetaMetricsId: false }, - ); await dispatch(startNewDraftTransaction({ type: AssetType.native })); history.push(SEND_ROUTE); } @@ -358,10 +406,12 @@ const CoinButtons = ({ event: MetaMetricsEventName.NavBuyButtonClicked, category: MetaMetricsEventCategory.Navigation, properties: { + account_type: account.type, location: 'Home', text: 'Buy', chain_id: chainId, token_symbol: defaultSwapsToken, + ...getSnapAccountMetaMetricsPropertiesIfAny(account), }, }); }, [chainId, defaultSwapsToken]); diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index 93d9e1061428..bf054d993e74 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -11,6 +11,7 @@ import { zeroAddress } from 'ethereumjs-util'; import { CaipChainId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { InternalAccount } from '@metamask/keyring-api'; import { Box, ButtonIcon, @@ -45,11 +46,12 @@ import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display import { PRIMARY } from '../../../helpers/constants/common'; import { getPreferences, - getSelectedAccount, getShouldHideZeroBalanceTokens, getTokensMarketData, getIsTestnet, getShouldShowAggregatedBalancePopover, + getIsTokenNetworkFilterEqualCurrentNetwork, + getChainIdsToPoll, ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) getDataCollectionForMarketing, getMetaMetricsId, @@ -60,8 +62,10 @@ import { import Spinner from '../../ui/spinner'; import { PercentageAndAmountChange } from '../../multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change'; -import { getMultichainIsEvm } from '../../../selectors/multichain'; -import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance'; +import { + getMultichainIsEvm, + getMultichainShouldShowFiat, +} from '../../../selectors/multichain'; import { setAggregatedBalancePopoverShown, setPrivacyMode, @@ -69,11 +73,17 @@ import { import { useTheme } from '../../../hooks/useTheme'; import { getSpecificSettingsRoute } from '../../../helpers/utils/settings-search'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; + +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; import WalletOverview from './wallet-overview'; import CoinButtons from './coin-buttons'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; +import { AggregatedPercentageOverviewCrossChains } from './aggregated-percentage-overview-cross-chains'; export type CoinOverviewProps = { + account: InternalAccount; balance: string; balanceIsCached: boolean; className?: string; @@ -90,6 +100,7 @@ export type CoinOverviewProps = { }; export const CoinOverview = ({ + account, balance, balanceIsCached, className, @@ -121,7 +132,6 @@ export const CoinOverview = ({ ///: END:ONLY_INCLUDE_IF - const account = useSelector(getSelectedAccount); const showNativeTokenAsMainBalanceRoute = getSpecificSettingsRoute( t, t('general'), @@ -135,22 +145,38 @@ export const CoinOverview = ({ const { showFiatInTestnets, privacyMode, showNativeTokenAsMainBalance } = useSelector(getPreferences); - const selectedAccount = useSelector(getSelectedAccount); + const isTokenNetworkFilterEqualCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); + const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); - const { totalFiatBalance, loading } = useAccountTotalFiatBalance( - selectedAccount, + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + account, shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ); + const { totalFiatBalance } = useAccountTotalCrossChainFiatBalance( + account, + formattedTokensWithBalancesPerChain, + ); + + const shouldShowFiat = useMultichainSelector( + getMultichainShouldShowFiat, + account, ); const isEvm = useSelector(getMultichainIsEvm); const isNotAggregatedFiatBalance = - showNativeTokenAsMainBalance || isTestnet || !isEvm; + !shouldShowFiat || showNativeTokenAsMainBalance || isTestnet || !isEvm; + let balanceToDisplay; if (isNotAggregatedFiatBalance) { balanceToDisplay = balance; - } else if (!loading) { + } else { balanceToDisplay = totalFiatBalance; } @@ -225,7 +251,13 @@ export const CoinOverview = ({ } return ( - + {isTokenNetworkFilterEqualCurrentNetwork || + !process.env.PORTFOLIO_VIEW ? ( + + ) : ( + + )} + { ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -341,21 +375,37 @@ export const CoinOverview = ({ - {t('aggregatedBalancePopover', [ - - {t('settings')} - , - ])} + {process.env.PORTFOLIO_VIEW + ? t('crossChainAggregatedBalancePopover', [ + + {t('settings')} + , + ]) + : t('aggregatedBalancePopover', [ + + {t('settings')} + , + ])} @@ -368,6 +418,7 @@ export const CoinOverview = ({ buttons={ { return ( jest.fn()); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + jest.mock('../../../hooks/useIsOriginalNativeTokenSymbol', () => { return { useIsOriginalNativeTokenSymbol: jest.fn(), @@ -24,6 +36,12 @@ jest.mock('../../../ducks/locale/locale', () => ({ getIntlLocale: jest.fn(), })); +jest.mock('../../../store/actions', () => ({ + startNewDraftTransaction: jest.fn(), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + const mockGetIntlLocale = getIntlLocale; let openTabSpy; @@ -32,22 +50,51 @@ describe('EthOverview', () => { useIsOriginalNativeTokenSymbol.mockReturnValue(true); mockGetIntlLocale.mockReturnValue('en-US'); + const mockEvmAccount1 = { + address: '0x1', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Account 1', + keyring: { + type: KeyringType.imported, + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }; + + const mockEvmAccount2 = { + address: '0x2', + id: 'e9b992f9-e151-4317-b8b7-c771bb73dd02', + metadata: { + name: 'Account 2', + keyring: { + type: KeyringType.imported, + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }; + const mockStore = { metamask: { ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), accountsByChainId: { [CHAIN_IDS.MAINNET]: { - '0x1': { address: '0x1', balance: '0x1F4' }, + '0x1': { address: mockEvmAccount1.address, balance: '0x1F4' }, }, }, tokenList: [], cachedBalances: { '0x1': { - '0x1': '0x1F4', + [mockEvmAccount1.address]: '0x1F4', }, }, preferences: { showNativeTokenAsMainBalance: true, + tokenNetworkFilter: {}, }, useExternalServices: true, useCurrencyRateCheck: true, @@ -58,46 +105,22 @@ describe('EthOverview', () => { }, }, accounts: { - '0x1': { - address: '0x1', + [mockEvmAccount1.address]: { + address: mockEvmAccount1.address, balance: '0x1F4', }, }, internalAccounts: { accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x1', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 1', - keyring: { - type: KeyringType.imported, - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - 'e9b992f9-e151-4317-b8b7-c771bb73dd02': { - address: '0x2', - id: 'e9b992f9-e151-4317-b8b7-c771bb73dd02', - metadata: { - name: 'Account 2', - keyring: { - type: KeyringType.imported, - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, + [mockEvmAccount1.id]: mockEvmAccount1, + [mockEvmAccount2.id]: mockEvmAccount2, }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + selectedAccount: mockEvmAccount1.id, }, keyrings: [ { type: KeyringType.imported, - accounts: ['0x1', '0x2'], + accounts: [mockEvmAccount1.address, mockEvmAccount2.address], }, { type: KeyringType.ledger, @@ -381,6 +404,36 @@ describe('EthOverview', () => { }); }); + it('sends an event when clicking the Buy button: %s', () => { + const mockTrackEvent = jest.fn(); + + const mockedStore = configureMockStore([thunk])(mockStore); + const { queryByTestId } = renderWithProvider( + + + , + mockedStore, + ); + + const buyButton = queryByTestId(ETH_OVERVIEW_BUY); + expect(buyButton).toBeInTheDocument(); + expect(buyButton).not.toBeDisabled(); + fireEvent.click(buyButton); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: MetaMetricsEventName.NavBuyButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockEvmAccount1.type, + chain_id: CHAIN_IDS.MAINNET, + location: 'Home', + text: 'Buy', + // We use a `SwapsEthToken` in this case, so we're expecting an entire object here. + token_symbol: expect.any(Object), + }, + }); + }); + describe('Disabled buttons when an account cannot sign transactions', () => { const buttonTestCases = [ { testId: ETH_OVERVIEW_SEND, buttonText: 'Send' }, @@ -391,15 +444,30 @@ describe('EthOverview', () => { it.each(buttonTestCases)( 'should have the $buttonText button disabled when an account cannot sign transactions or user operations', ({ testId, buttonText }) => { - mockStore.metamask.internalAccounts.accounts[ - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3' - ].methods = Object.values(EthMethod).filter( - (method) => - method !== EthMethod.SignTransaction && - method !== EthMethod.SignUserOperation, - ); + const mockedStoreWithoutSigningMethods = { + ...mockStore, + metamask: { + ...mockStore.metamask, + internalAccounts: { + ...mockStore.metamask.internalAccounts, + accounts: { + [mockEvmAccount1.id]: { + ...mockEvmAccount1, + // Filter out all methods used for signing transactions. + methods: Object.values(EthMethod).filter( + (method) => + method !== EthMethod.SignTransaction && + method !== EthMethod.SignUserOperation, + ), + }, + }, + }, + }, + }; - const mockedStore = configureMockStore([thunk])(mockStore); + const mockedStore = configureMockStore([thunk])( + mockedStoreWithoutSigningMethods, + ); const { queryByTestId, queryByText } = renderWithProvider( , mockedStore, @@ -415,4 +483,50 @@ describe('EthOverview', () => { }, ); }); + + it.each([ + CHAIN_IDS.MAINNET, + // We want to test with a different chain ID than mainnet to make sure the events are still using + // the right `token_symbol`. + CHAIN_IDS.SEPOLIA, + ])('sends an event when clicking the Send button: %s', (chainId) => { + const mockTrackEvent = jest.fn(); + const mockedStoreWithSpecificChainId = { + ...mockStore, + metamask: { + ...mockStore.metamask, + ...mockNetworkState({ chainId }), + }, + }; + + const mockedStore = configureMockStore([thunk])( + mockedStoreWithSpecificChainId, + ); + const { queryByTestId } = renderWithProvider( + + + , + mockedStore, + ); + + const sendButton = queryByTestId(ETH_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + fireEvent.click(sendButton); + + expect(mockTrackEvent).toHaveBeenCalledWith( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockEvmAccount1.type, + chain_id: chainId, + location: 'Home', + text: 'Send', + token_symbol: 'ETH', + }, + }, + expect.any(Object), + ); + }); }); diff --git a/ui/components/multichain/account-list-item/account-list-item.js b/ui/components/multichain/account-list-item/account-list-item.js index 143c4d142a16..207b8fa0fe58 100644 --- a/ui/components/multichain/account-list-item/account-list-item.js +++ b/ui/components/multichain/account-list-item/account-list-item.js @@ -47,8 +47,11 @@ import { import { MetaMetricsContext } from '../../../contexts/metametrics'; import { isAccountConnectedToCurrentTab, - getShowFiatInTestnets, getUseBlockie, + getShouldHideZeroBalanceTokens, + getIsTokenNetworkFilterEqualCurrentNetwork, + getShowFiatInTestnets, + getChainIdsToPoll, } from '../../../selectors'; import { getMultichainIsTestnet, @@ -67,6 +70,8 @@ import { useTheme } from '../../../hooks/useTheme'; // eslint-disable-next-line import/no-restricted-paths import { normalizeSafeAddress } from '../../../../app/scripts/lib/multichain/address'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; +import { useGetFormattedTokensPerChain } from '../../../hooks/useGetFormattedTokensPerChain'; +import { useAccountTotalCrossChainFiatBalance } from '../../../hooks/useAccountTotalCrossChainFiatBalance'; import { AccountListItemMenuTypes } from './account-list-item.types'; const MAXIMUM_CURRENCY_DECIMALS = 3; @@ -99,6 +104,7 @@ const AccountListItem = ({ const setAccountListItemMenuRef = (ref) => { setAccountListItemMenuElement(ref); }; + const isTestnet = useMultichainSelector(getMultichainIsTestnet, account); const isMainnet = !isTestnet; const shouldShowFiat = useMultichainSelector( @@ -110,14 +116,39 @@ const AccountListItem = ({ shouldShowFiat && (isMainnet || (isTestnet && showFiatInTestnets)); const accountTotalFiatBalances = useMultichainAccountTotalFiatBalance(account); + // cross chain agg balance + const shouldHideZeroBalanceTokens = useSelector( + getShouldHideZeroBalanceTokens, + ); + const isTokenNetworkFilterEqualCurrentNetwork = useSelector( + getIsTokenNetworkFilterEqualCurrentNetwork, + ); + const allChainIDs = useSelector(getChainIdsToPoll); + const { formattedTokensWithBalancesPerChain } = useGetFormattedTokensPerChain( + account, + shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ); + const { totalFiatBalance } = useAccountTotalCrossChainFiatBalance( + account, + formattedTokensWithBalancesPerChain, + ); + // cross chain agg balance const mappedOrderedTokenList = accountTotalFiatBalances.orderedTokenList.map( (item) => ({ avatarValue: item.iconUrl, }), ); - const balanceToTranslate = isEvmNetwork - ? account.balance - : accountTotalFiatBalances.totalBalance; + let balanceToTranslate; + if (isEvmNetwork) { + balanceToTranslate = + !shouldShowFiat || isTestnet || !process.env.PORTFOLIO_VIEW + ? account.balance + : totalFiatBalance; + } else { + balanceToTranslate = accountTotalFiatBalances.totalBalance; + } ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const custodianIcon = useSelector((state) => @@ -313,6 +344,9 @@ const AccountListItem = ({ value={balanceToTranslate} type={PRIMARY} showFiat={showFiat} + isAggregatedFiatOverviewBalance={ + !isTestnet && process.env.PORTFOLIO_VIEW && shouldShowFiat + } data-testid="first-currency-display" privacyMode={privacyMode} /> diff --git a/ui/components/multichain/account-list-menu/account-list-menu.test.tsx b/ui/components/multichain/account-list-menu/account-list-menu.test.tsx index 7a849577dfa4..539899d0eb09 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.test.tsx +++ b/ui/components/multichain/account-list-menu/account-list-menu.test.tsx @@ -7,27 +7,32 @@ import { KeyringAccountType, } from '@metamask/keyring-api'; import { merge } from 'lodash'; -import { fireEvent, renderWithProvider, waitFor } from '../../../../test/jest'; +import { fireEvent, waitFor } from '../../../../test/jest'; import configureStore from '../../../store/store'; import mockState from '../../../../test/data/mock-state.json'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import messages from '../../../../app/_locales/en/messages.json'; -import { CONNECT_HARDWARE_ROUTE } from '../../../helpers/constants/routes'; +import { + CONFIRMATION_V_NEXT_ROUTE, + CONNECT_HARDWARE_ROUTE, +} from '../../../helpers/constants/routes'; ///: END:ONLY_INCLUDE_IF import { ETH_EOA_METHODS } from '../../../../shared/constants/eth-methods'; import { createMockInternalAccount } from '../../../../test/jest/mocks'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; import { AccountListMenu } from '.'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const mockOnClose = jest.fn(); const mockGetEnvironmentType = jest.fn(); const mockNextAccountName = jest.fn().mockReturnValue('Test Account 2'); +const mockBitcoinClientCreateAccount = jest.fn(); jest.mock('../../../../app/scripts/lib/util', () => ({ ...jest.requireActual('../../../../app/scripts/lib/util'), - getEnvironmentType: () => mockGetEnvironmentType, + getEnvironmentType: () => () => mockGetEnvironmentType(), })); ///: END:ONLY_INCLUDE_IF @@ -43,6 +48,15 @@ jest.mock('react-router-dom', () => ({ useHistory: jest.fn(() => []), })); +jest.mock('../../../hooks/accounts/useMultichainWalletSnapClient', () => ({ + ...jest.requireActual( + '../../../hooks/accounts/useMultichainWalletSnapClient', + ), + useMultichainWalletSnapClient: () => ({ + createAccount: mockBitcoinClientCreateAccount, + }), +})); + const render = ( state = {}, props: { @@ -52,6 +66,7 @@ const render = ( onClose: () => jest.fn(), allowedAccountTypes: [EthAccountType.Eoa, EthAccountType.Erc4337], }, + location: string = '/', ) => { const defaultState = { ...mockState, @@ -82,6 +97,7 @@ const render = ( }, }, }, + bitcoinSupportEnabled: true, }, activeTab: { id: 113, @@ -95,7 +111,7 @@ const render = ( }, }; const store = configureStore(merge(defaultState, state)); - return renderWithProvider(, store); + return renderWithProvider(, store, location); }; describe('AccountListMenu', () => { @@ -512,6 +528,51 @@ describe('AccountListMenu', () => { }); ///: END:ONLY_INCLUDE_IF + describe('BTC account creation', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('calls the bitcoin client to create an account', async () => { + const { getByText, getByTestId } = render(); + + const button = getByTestId( + 'multichain-account-menu-popover-action-button', + ); + button.click(); + + const createBtcAccountButton = getByText( + messages.addNewBitcoinAccount.message, + ); + + createBtcAccountButton.click(); + + expect(mockBitcoinClientCreateAccount).toHaveBeenCalled(); + }); + + it('redirects the user to the approval after clicking create account in the settings page', async () => { + const { getByText, getByTestId } = render( + undefined, + undefined, + '/settings', + ); + + const button = getByTestId( + 'multichain-account-menu-popover-action-button', + ); + button.click(); + + const createBtcAccountButton = getByText( + messages.addNewBitcoinAccount.message, + ); + + createBtcAccountButton.click(); + + expect(historyPushMock).toHaveBeenCalledWith(CONFIRMATION_V_NEXT_ROUTE); + expect(mockBitcoinClientCreateAccount).toHaveBeenCalled(); + }); + }); + describe('prop `allowedAccountTypes`', () => { const mockAccount = createMockInternalAccount(); const mockBtcAccount = createMockInternalAccount({ diff --git a/ui/components/multichain/account-list-menu/account-list-menu.tsx b/ui/components/multichain/account-list-menu/account-list-menu.tsx index 75d04e9e60e0..29d79e8537b1 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.tsx +++ b/ui/components/multichain/account-list-menu/account-list-menu.tsx @@ -6,7 +6,12 @@ import React, { useEffect, } from 'react'; import PropTypes from 'prop-types'; -import { useHistory } from 'react-router-dom'; +import { + useHistory, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + useLocation, + ///: END:ONLY_INCLUDE_IF +} from 'react-router-dom'; import Fuse from 'fuse.js'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -68,6 +73,7 @@ import { getOriginOfCurrentTab, getSelectedInternalAccount, getUpdatedAndSortedAccounts, + getDefaultHomeActiveTabName, ///: BEGIN:ONLY_INCLUDE_IF(solana) getIsSolanaSupportEnabled, ///: END:ONLY_INCLUDE_IF @@ -80,6 +86,10 @@ import { } from '../../../../shared/constants/metametrics'; import { CONNECT_HARDWARE_ROUTE, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + CONFIRMATION_V_NEXT_ROUTE, + SETTINGS_ROUTE, + ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) CUSTODY_ACCOUNT_ROUTE, ///: END:ONLY_INCLUDE_IF @@ -114,7 +124,11 @@ import { AccountConnections, MergedInternalAccount, } from '../../../selectors/selectors.types'; -import { endTrace, TraceName } from '../../../../shared/lib/trace'; +import { endTrace, trace, TraceName } from '../../../../shared/lib/trace'; +import { + ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP, + AccountOverviewTabKey, +} from '../../../../shared/constants/app-state'; ///: BEGIN:ONLY_INCLUDE_IF(solana) import { SOLANA_WALLET_NAME, @@ -242,6 +256,9 @@ export const AccountListMenu = ({ const currentTabOrigin = useSelector(getOriginOfCurrentTab); const history = useHistory(); const dispatch = useDispatch(); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + const { pathname } = useLocation(); + ///: END:ONLY_INCLUDE_IF const hiddenAddresses = useSelector(getHiddenAccountsList); const updatedAccountsList = useSelector(getUpdatedAndSortedAccounts); const filteredUpdatedAccountList = useMemo( @@ -251,6 +268,9 @@ export const AccountListMenu = ({ ), [updatedAccountsList, allowedAccountTypes], ); + const defaultHomeActiveTabName: AccountOverviewTabKey = useSelector( + getDefaultHomeActiveTabName, + ); ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) const addSnapAccountEnabled = useSelector(getIsAddSnapAccountEnabled); ///: END:ONLY_INCLUDE_IF @@ -287,6 +307,17 @@ export const AccountListMenu = ({ const bitcoinWalletSnapClient = useMultichainWalletSnapClient( WalletClientType.Bitcoin, ); + const handleAccountCreation = async (network: MultichainNetworks) => { + // The account creation + renaming is handled by the Snap account bridge, so + // we need to close the current modal + onClose(); + if (pathname.includes(SETTINGS_ROUTE)) { + // The settings route does not redirect pending confirmations. We need to redirect manually here. + history.push(CONFIRMATION_V_NEXT_ROUTE); + } + + await bitcoinWalletSnapClient.createAccount(network); + }; ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(solana) @@ -294,6 +325,7 @@ export const AccountListMenu = ({ const solanaWalletSnapClient = useMultichainWalletSnapClient( WalletClientType.Solana, ); + ///: END:ONLY_INCLUDE_IF const [searchQuery, setSearchQuery] = useState(''); @@ -340,6 +372,16 @@ export const AccountListMenu = ({ location: 'Main Menu', }, }); + endTrace({ + name: ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP[ + defaultHomeActiveTabName + ], + }); + trace({ + name: ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP[ + defaultHomeActiveTabName + ], + }); dispatch(setSelectedAccount(account.address)); }; }, @@ -435,14 +477,7 @@ export const AccountListMenu = ({ }, }); - // The account creation + renaming is handled by the - // Snap account bridge, so we need to close the current - // modal - onClose(); - - await bitcoinWalletSnapClient.createAccount( - MultichainNetworks.BITCOIN, - ); + await handleAccountCreation(MultichainNetworks.BITCOIN); }} data-testid="multichain-account-menu-popover-add-btc-account" > @@ -461,11 +496,7 @@ export const AccountListMenu = ({ size={ButtonLinkSize.Sm} startIconName={IconName.Add} onClick={async () => { - // The account creation + renaming is handled by the Snap account bridge, so - // we need to close the current modal - onClose(); - - await bitcoinWalletSnapClient.createAccount( + await handleAccountCreation( MultichainNetworks.BITCOIN_TESTNET, ); }} diff --git a/ui/components/multichain/account-overview/account-overview-btc.test.tsx b/ui/components/multichain/account-overview/account-overview-btc.test.tsx index 9d265657432b..b171840a540e 100644 --- a/ui/components/multichain/account-overview/account-overview-btc.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-btc.test.tsx @@ -3,13 +3,31 @@ import mockState from '../../../../test/data/mock-state.json'; import configureStore from '../../../store/store'; import { renderWithProvider } from '../../../../test/jest/rendering'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { AccountOverviewBtc, AccountOverviewBtcProps, } from './account-overview-btc'; +jest.mock('../../../store/actions', () => ({ + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), +})); + +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + const defaultProps: AccountOverviewBtcProps = { - defaultHomeActiveTabName: '', + defaultHomeActiveTabName: null, onTabClick: jest.fn(), setBasicFunctionalityModalOpen: jest.fn(), onSupportLinkClick: jest.fn(), @@ -17,7 +35,16 @@ const defaultProps: AccountOverviewBtcProps = { const render = (props: AccountOverviewBtcProps = defaultProps) => { const store = configureStore({ - metamask: mockState.metamask, + metamask: { + ...mockState.metamask, + preferences: { + ...mockState.metamask.preferences, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + }, }); return renderWithProvider(, store); @@ -25,7 +52,10 @@ const render = (props: AccountOverviewBtcProps = defaultProps) => { describe('AccountOverviewBtc', () => { beforeEach(() => { - setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); + setBackgroundConnection({ + setBridgeFeatureFlags: jest.fn(), + tokenBalancesStartPolling: jest.fn(), + } as never); }); it('shows only Tokens and Activity tabs', () => { diff --git a/ui/components/multichain/account-overview/account-overview-eth.test.tsx b/ui/components/multichain/account-overview/account-overview-eth.test.tsx index f6f67892942a..a886608ec169 100644 --- a/ui/components/multichain/account-overview/account-overview-eth.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-eth.test.tsx @@ -3,14 +3,41 @@ import mockState from '../../../../test/data/mock-state.json'; import configureStore from '../../../store/store'; import { renderWithProvider } from '../../../../test/jest/rendering'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { CHAIN_IDS } from '../../../../shared/constants/network'; import { AccountOverviewEth, AccountOverviewEthProps, } from './account-overview-eth'; +jest.mock('../../../store/actions', () => ({ + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), +})); + +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + const render = (props: AccountOverviewEthProps) => { const store = configureStore({ - metamask: mockState.metamask, + metamask: { + ...mockState.metamask, + preferences: { + ...mockState.metamask.preferences, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + }, }); return renderWithProvider(, store); @@ -18,11 +45,14 @@ const render = (props: AccountOverviewEthProps) => { describe('AccountOverviewEth', () => { beforeEach(() => { - setBackgroundConnection({ setBridgeFeatureFlags: jest.fn() } as never); + setBackgroundConnection({ + setBridgeFeatureFlags: jest.fn(), + tokenBalancesStartPolling: jest.fn(), + } as never); }); it('shows all tabs', () => { const { queryByTestId } = render({ - defaultHomeActiveTabName: '', + defaultHomeActiveTabName: null, onTabClick: jest.fn(), setBasicFunctionalityModalOpen: jest.fn(), onSupportLinkClick: jest.fn(), diff --git a/ui/components/multichain/account-overview/account-overview-tabs.tsx b/ui/components/multichain/account-overview/account-overview-tabs.tsx index 554d9fb0acfc..8c356f9f9141 100644 --- a/ui/components/multichain/account-overview/account-overview-tabs.tsx +++ b/ui/components/multichain/account-overview/account-overview-tabs.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { endTrace, trace } from '../../../../shared/lib/trace'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { ASSET_ROUTE } from '../../../helpers/constants/routes'; import { @@ -7,10 +9,7 @@ import { SUPPORT_LINK, ///: END:ONLY_INCLUDE_IF } from '../../../../shared/lib/ui-utils'; -import { - MetaMetricsEventCategory, - MetaMetricsEventName, -} from '../../../../shared/constants/metametrics'; +import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import NftsTab from '../../app/assets/nfts/nfts-tab'; import AssetList from '../../app/assets/asset-list'; @@ -37,6 +36,12 @@ import { ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) import InstitutionalHomeFooter from '../../../pages/home/institutional/institutional-home-footer'; ///: END:ONLY_INCLUDE_IF +import { + ACCOUNT_OVERVIEW_TAB_KEY_TO_METAMETRICS_EVENT_NAME_MAP, + ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP, + AccountOverviewTabKey, +} from '../../../../shared/constants/app-state'; +import { detectNfts } from '../../../store/actions'; import { AccountOverviewCommonProps } from './common'; export type AccountOverviewTabsProps = AccountOverviewCommonProps & { @@ -60,6 +65,7 @@ export const AccountOverviewTabs = ({ const history = useHistory(); const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); + const dispatch = useDispatch(); const tabProps = useMemo( () => ({ @@ -69,24 +75,24 @@ export const AccountOverviewTabs = ({ [], ); - const getEventFromTabName = (tabName: string) => { - switch (tabName) { - case 'nfts': - return MetaMetricsEventName.NftScreenOpened; - case 'activity': - return MetaMetricsEventName.ActivityScreenOpened; - default: - return MetaMetricsEventName.TokenScreenOpened; - } - }; - const handleTabClick = useCallback( - (tabName: string) => { + (tabName: AccountOverviewTabKey) => { onTabClick(tabName); + if (tabName === AccountOverviewTabKey.Nfts) { + dispatch(detectNfts()); + } trackEvent({ category: MetaMetricsEventCategory.Home, - event: getEventFromTabName(tabName), + event: ACCOUNT_OVERVIEW_TAB_KEY_TO_METAMETRICS_EVENT_NAME_MAP[tabName], }); + if (defaultHomeActiveTabName) { + endTrace({ + name: ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP[ + defaultHomeActiveTabName + ], + }); + } + trace({ name: ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP[tabName] }); }, [onTabClick], ); @@ -143,7 +149,9 @@ export const AccountOverviewTabs = ({ + // TODO: chainID to be incorporated in unified asset list + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onClickAsset={(chainId: string, asset: string) => history.push(`${ASSET_ROUTE}/${asset}`) } /> diff --git a/ui/components/multichain/account-overview/account-overview-unknown.test.tsx b/ui/components/multichain/account-overview/account-overview-unknown.test.tsx index 59fe2a0c23bc..0d46ff86275f 100644 --- a/ui/components/multichain/account-overview/account-overview-unknown.test.tsx +++ b/ui/components/multichain/account-overview/account-overview-unknown.test.tsx @@ -18,7 +18,7 @@ const render = (props: AccountOverviewUnknownProps) => { describe('AccountOverviewUnknown', () => { it('shows only the activity tab', () => { const { queryByTestId } = render({ - defaultHomeActiveTabName: '', + defaultHomeActiveTabName: null, onTabClick: jest.fn(), setBasicFunctionalityModalOpen: jest.fn(), onSupportLinkClick: jest.fn(), diff --git a/ui/components/multichain/account-overview/common.ts b/ui/components/multichain/account-overview/common.ts index 7bee1928706b..05957cb37cf1 100644 --- a/ui/components/multichain/account-overview/common.ts +++ b/ui/components/multichain/account-overview/common.ts @@ -1,8 +1,10 @@ +import { AccountOverviewTabKey } from '../../../../shared/constants/app-state'; + export type AccountOverviewCommonProps = { onTabClick: (tabName: string) => void; setBasicFunctionalityModalOpen: () => void; ///: BEGIN:ONLY_INCLUDE_IF(build-main) onSupportLinkClick: () => void; ///: END:ONLY_INCLUDE_IF - defaultHomeActiveTabName: string; + defaultHomeActiveTabName: AccountOverviewTabKey | null; }; diff --git a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap index 8d840ba595ce..c3895c50d76a 100644 --- a/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap +++ b/ui/components/multichain/address-list-item/__snapshots__/address-list-item.test.tsx.snap @@ -1,6 +1,106 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AddressListItem renders the address and label 1`] = ` +exports[`AddressListItem displays duplicate contact warning icon 1`] = ` +
+ +
+`; + +exports[`AddressListItem renders the address and label without duplicate contact warning icon 1`] = `
-
-
-
-`; - -exports[`Import Token Link should match snapshot for mainnet chainId 1`] = ` -
- -
-`; diff --git a/ui/components/multichain/import-token-link/import-token-link.stories.tsx b/ui/components/multichain/import-token-link/import-token-link.stories.tsx deleted file mode 100644 index 85c09e2184dd..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { StoryFn, Meta } from '@storybook/react'; -import { ImportTokenLink } from '.'; - -export default { - title: 'Components/Multichain/ImportTokenLink', - component: ImportTokenLink, -} as Meta; - -export const DefaultStory: StoryFn = () => ( - -); - -DefaultStory.storyName = 'Default'; diff --git a/ui/components/multichain/import-token-link/import-token-link.test.js b/ui/components/multichain/import-token-link/import-token-link.test.js deleted file mode 100644 index 641a39d1bff3..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import configureMockStore from 'redux-mock-store'; -import { fireEvent, screen } from '@testing-library/react'; -import { detectTokens } from '../../../store/actions'; -import { renderWithProvider } from '../../../../test/lib/render-helpers'; -import { CHAIN_IDS } from '../../../../shared/constants/network'; -import { mockNetworkState } from '../../../../test/stub/networks'; -import ImportControl from '../../app/assets/asset-list/import-control'; -import { ImportTokenLink } from '.'; - -const mockPushHistory = jest.fn(); - -jest.mock('react-router-dom', () => { - const original = jest.requireActual('react-router-dom'); - return { - ...original, - useLocation: jest.fn(() => ({ search: '' })), - useHistory: () => ({ - push: mockPushHistory, - }), - }; -}); - -jest.mock('../../../store/actions.ts', () => ({ - detectTokens: jest.fn().mockImplementation(() => ({ type: 'DETECT_TOKENS' })), - showImportTokensModal: jest - .fn() - .mockImplementation(() => ({ type: 'UI_IMPORT_TOKENS_POPOVER_OPEN' })), -})); - -describe('Import Token Link', () => { - it('should match snapshot for goerli chainId', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - const { container } = renderWithProvider(, store); - - expect(container).toMatchSnapshot(); - }); - - it('should match snapshot for mainnet chainId', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - }, - }; - - const store = configureMockStore()(mockState); - - const { container } = renderWithProvider(, store); - - expect(container).toMatchSnapshot(); - }); - - it('should detectTokens when clicking refresh', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - renderWithProvider(, store); // should this be RefreshTokenLink? - - const refreshList = screen.getByTestId('refresh-list-button'); - fireEvent.click(refreshList); - - expect(detectTokens).toHaveBeenCalled(); - }); - - it('should push import token route', () => { - const mockState = { - metamask: { - ...mockNetworkState({ chainId: CHAIN_IDS.GOERLI }), - }, - }; - - const store = configureMockStore()(mockState); - - renderWithProvider(, store); - - const importToken = screen.getByTestId('import-token-button'); - fireEvent.click(importToken); - - expect(screen.getByText('Import')).toBeInTheDocument(); - }); -}); diff --git a/ui/components/multichain/import-token-link/import-token-link.tsx b/ui/components/multichain/import-token-link/import-token-link.tsx deleted file mode 100644 index 022369dd5002..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import { useDispatch } from 'react-redux'; -import classnames from 'classnames'; -import { - ButtonLink, - IconName, - Box, - ButtonLinkSize, -} from '../../component-library'; -import { AlignItems, Display } from '../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../hooks/useI18nContext'; -import { detectTokens } from '../../../store/actions'; -import type { BoxProps } from '../../component-library/box'; -import type { ImportTokenLinkProps } from './import-token-link.types'; - -export const ImportTokenLink: React.FC = ({ - className = '', - ...props -}): JSX.Element => { - const t = useI18nContext(); - const dispatch = useDispatch(); - - return ( - )} - > - - dispatch(detectTokens())} - > - {t('refreshList')} - - - - ); -}; diff --git a/ui/components/multichain/import-token-link/import-token-link.types.ts b/ui/components/multichain/import-token-link/import-token-link.types.ts deleted file mode 100644 index 0ad9350ca998..000000000000 --- a/ui/components/multichain/import-token-link/import-token-link.types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { StyleUtilityProps } from '../../component-library/box'; - -// TODO: Convert to a `type` in a future major version. -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export interface ImportTokenLinkProps extends StyleUtilityProps { - /** * Additional class name for the ImportTokenLink component. */ - className?: string; -} diff --git a/ui/components/multichain/import-token-link/index.ts b/ui/components/multichain/import-token-link/index.ts deleted file mode 100644 index db139df76890..000000000000 --- a/ui/components/multichain/import-token-link/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ImportTokenLink } from './import-token-link'; diff --git a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js index 0123bc91b147..b9ef596b8f07 100644 --- a/ui/components/multichain/import-tokens-modal/import-tokens-modal.js +++ b/ui/components/multichain/import-tokens-modal/import-tokens-modal.js @@ -493,7 +493,6 @@ export const ImportTokensModal = ({ onClose }) => { return ( { dispatch(clearPendingTokens()); onClose(); diff --git a/ui/components/multichain/index.js b/ui/components/multichain/index.js index 5ecc5a2a7d3a..10b0a61b3eef 100644 --- a/ui/components/multichain/index.js +++ b/ui/components/multichain/index.js @@ -7,7 +7,6 @@ export { ActivityListItem } from './activity-list-item'; export { AppHeader } from './app-header'; export { DetectedTokensBanner } from './detected-token-banner'; export { GlobalMenu } from './global-menu'; -export { ImportTokenLink } from './import-token-link'; export { TokenListItem } from './token-list-item'; export { AddressCopyButton } from './address-copy-button'; export { ConnectedSiteMenu } from './connected-site-menu'; diff --git a/ui/components/multichain/pages/send/components/address-book.tsx b/ui/components/multichain/pages/send/components/address-book.tsx index 8100646486ca..7344067eca20 100644 --- a/ui/components/multichain/pages/send/components/address-book.tsx +++ b/ui/components/multichain/pages/send/components/address-book.tsx @@ -7,6 +7,7 @@ import ContactList from '../../../../app/contact-list'; import { getAddressBook, getCurrentNetworkTransactions, + getInternalAccounts, } from '../../../../../selectors'; import { addHistoryEntry, @@ -33,6 +34,7 @@ export const SendPageAddressBook = () => { const trackEvent = useContext(MetaMetricsContext); const addressBook = useSelector(getAddressBook); + const internalAccounts = useSelector(getInternalAccounts); const contacts = addressBook.filter(({ name }) => Boolean(name)); const currentNetworkTransactions = useSelector(getCurrentNetworkTransactions); @@ -120,6 +122,7 @@ export const SendPageAddressBook = () => { <> { diff --git a/ui/components/multichain/pages/send/components/domain-input-resolution-cell.tsx b/ui/components/multichain/pages/send/components/domain-input-resolution-cell.tsx index 6ff8a6292afc..aeba83e4521f 100644 --- a/ui/components/multichain/pages/send/components/domain-input-resolution-cell.tsx +++ b/ui/components/multichain/pages/send/components/domain-input-resolution-cell.tsx @@ -97,6 +97,7 @@ export const DomainInputResolutionCell = ({ alignItems={AlignItems.center} paddingBottom={2} style={{ cursor: 'pointer' }} + data-testid="multichain-send-page__recipient__item" > { onClick: jest.fn(), tokenImage: '', title: '', + chainId: '0x1', }; it('should render correctly', () => { const store = configureMockStore()(state); @@ -99,6 +100,7 @@ describe('TokenListItem', () => { isOriginalTokenSymbol: false, tokenImage: '', title: '', + chainId: '0x1', }; const { getByText } = renderWithProvider( , @@ -117,6 +119,7 @@ describe('TokenListItem', () => { tokenImage: '', title: '', tokenSymbol: 'SCAM_TOKEN', + chainId: '0x1', }; const { getByTestId, getByText } = renderWithProvider( , @@ -144,6 +147,7 @@ describe('TokenListItem', () => { tokenImage: '', title: '', tokenSymbol: 'SCAM_TOKEN', + chainId: '0x1', }; const { getByTestId, getByText } = renderWithProvider( , @@ -171,6 +175,7 @@ describe('TokenListItem', () => { isOriginalTokenSymbol: false, tokenImage: '', title: '', + chainId: '0x1', }; const { getByText } = renderWithProvider( diff --git a/ui/components/multichain/token-list-item/token-list-item.tsx b/ui/components/multichain/token-list-item/token-list-item.tsx index bf3968963465..1f9f0b18bed5 100644 --- a/ui/components/multichain/token-list-item/token-list-item.tsx +++ b/ui/components/multichain/token-list-item/token-list-item.tsx @@ -46,7 +46,6 @@ import { getDataCollectionForMarketing, } from '../../../selectors'; import { - getMultichainCurrentChainId, getMultichainCurrentNetwork, getMultichainIsEvm, } from '../../../selectors/multichain'; @@ -78,6 +77,7 @@ type TokenListItemProps = { secondary?: string | null; title: string; tooltipText?: string; + chainId: string; isOriginalTokenSymbol?: boolean | null; isNativeCurrency?: boolean; isStakeable?: boolean; @@ -96,6 +96,7 @@ export const TokenListItem = ({ secondary, title, tooltipText, + chainId, isOriginalTokenSymbol, isPrimaryTokenSymbolHidden = false, isNativeCurrency = false, @@ -107,7 +108,6 @@ export const TokenListItem = ({ const t = useI18nContext(); const isEvm = useSelector(getMultichainIsEvm); const trackEvent = useContext(MetaMetricsContext); - const chainId = useSelector(getMultichainCurrentChainId); const metaMetricsId = useSelector(getMetaMetricsId); const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); const isMarketingEnabled = useSelector(getDataCollectionForMarketing); @@ -302,6 +302,7 @@ export const TokenListItem = ({ as="span" fontWeight={FontWeight.Medium} variant={TextVariant.bodyMd} + display={Display.Block} ellipsis > {isStakeable ? ( diff --git a/ui/components/ui/tabs/tabs.component.js b/ui/components/ui/tabs/tabs.component.js index 9795f697fce3..e96576711ceb 100644 --- a/ui/components/ui/tabs/tabs.component.js +++ b/ui/components/ui/tabs/tabs.component.js @@ -1,14 +1,12 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { useDispatch } from 'react-redux'; import Box from '../box'; import { BackgroundColor, DISPLAY, JustifyContent, } from '../../../helpers/constants/design-system'; -import { detectNfts } from '../../../store/actions'; const Tabs = ({ defaultActiveTabKey, @@ -22,7 +20,6 @@ const Tabs = ({ const _getValidChildren = () => { return React.Children.toArray(children).filter(Boolean); }; - const dispatch = useDispatch(); /** * Returns the index of the child with the given key @@ -44,10 +41,6 @@ const Tabs = ({ setActiveTabIndex(tabIndex); onTabClick?.(tabKey); } - - if (tabKey === 'nfts') { - dispatch(detectNfts()); - } }; const renderTabs = () => { diff --git a/ui/contexts/assetPolling.tsx b/ui/contexts/assetPolling.tsx index 63cef9667fbd..afe80a23a050 100644 --- a/ui/contexts/assetPolling.tsx +++ b/ui/contexts/assetPolling.tsx @@ -1,6 +1,9 @@ import React, { ReactNode } from 'react'; import useCurrencyRatePolling from '../hooks/useCurrencyRatePolling'; import useTokenRatesPolling from '../hooks/useTokenRatesPolling'; +import useAccountTrackerPolling from '../hooks/useAccountTrackerPolling'; +import useTokenDetectionPolling from '../hooks/useTokenDetectionPolling'; +import useTokenListPolling from '../hooks/useTokenListPolling'; // This provider is a step towards making controller polling fully UI based. // Eventually, individual UI components will call the use*Polling hooks to @@ -8,6 +11,9 @@ import useTokenRatesPolling from '../hooks/useTokenRatesPolling'; export const AssetPollingProvider = ({ children }: { children: ReactNode }) => { useCurrencyRatePolling(); useTokenRatesPolling(); + useAccountTrackerPolling(); + useTokenDetectionPolling(); + useTokenListPolling(); return <>{children}; }; diff --git a/ui/contexts/metametrics.js b/ui/contexts/metametrics.js index 6445362f1f60..b7870babfbb7 100644 --- a/ui/contexts/metametrics.js +++ b/ui/contexts/metametrics.js @@ -26,7 +26,7 @@ import { trackMetaMetricsEvent, trackMetaMetricsPage } from '../store/actions'; // type imports /** - * @typedef {import('../../shared/constants/metametrics').MetaMetricsEventPayload} MetaMetricsEventPayload + * @typedef {import('../../shared/constants/metametrics').UnsanitizedMetaMetricsEventPayload} MetaMetricsEventPayload * @typedef {import('../../shared/constants/metametrics').MetaMetricsEventOptions} MetaMetricsEventOptions * @typedef {import('../../shared/constants/metametrics').MetaMetricsPageObject} MetaMetricsPageObject * @typedef {import('../../shared/constants/metametrics').MetaMetricsReferrerObject} MetaMetricsReferrerObject diff --git a/ui/css/utilities/fonts.scss b/ui/css/utilities/fonts.scss index a189b49d9a94..a61ec03ad0ab 100644 --- a/ui/css/utilities/fonts.scss +++ b/ui/css/utilities/fonts.scss @@ -25,28 +25,28 @@ $font-path: './fonts'; font-family: 'Euclid Circular B'; font-style: normal; font-weight: 400; - src: url('#{$font-path}/Euclid/EuclidCircularB-Regular-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Regular-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: italic; font-weight: 400; - src: url('#{$font-path}/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: normal; font-weight: 500; - src: url('#{$font-path}/Euclid/EuclidCircularB-Medium.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Medium-WebXL.woff2') format('woff2'); } @font-face { font-family: 'Euclid Circular B'; font-style: normal; font-weight: 700; - src: url('#{$font-path}/Euclid/EuclidCircularB-Bold-WebXL.ttf') format('truetype'); + src: url('#{$font-path}/Euclid/EuclidCircularB-Bold-WebXL.woff2') format('woff2'); } // Brand Evolution Font Families diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf deleted file mode 100644 index 244ebba0a481..000000000000 Binary files a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.ttf and /dev/null differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 new file mode 100644 index 000000000000..a94605d95a59 Binary files /dev/null and b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Bold-WebXL.woff2 differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 new file mode 100644 index 000000000000..4b4651673e74 Binary files /dev/null and b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium-WebXL.woff2 differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf deleted file mode 100644 index 7a2ae44a166d..000000000000 Binary files a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Medium.ttf and /dev/null differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.ttf deleted file mode 100644 index 6cf177fe1b23..000000000000 Binary files a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.ttf and /dev/null differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.woff2 new file mode 100644 index 000000000000..7e8c72d30549 Binary files /dev/null and b/ui/css/utilities/fonts/Euclid/EuclidCircularB-Regular-WebXL.woff2 differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf b/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf deleted file mode 100644 index 35345ba0f23b..000000000000 Binary files a/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.ttf and /dev/null differ diff --git a/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 b/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 new file mode 100644 index 000000000000..5235d39a6eba Binary files /dev/null and b/ui/css/utilities/fonts/Euclid/EuclidCircularB-RegularItalic-WebXL.woff2 differ diff --git a/ui/ducks/bridge/actions.ts b/ui/ducks/bridge/actions.ts index c3854bdc4ae8..a61d2fdcd8fd 100644 --- a/ui/ducks/bridge/actions.ts +++ b/ui/ducks/bridge/actions.ts @@ -7,11 +7,10 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths } from '../../../app/scripts/controllers/bridge/types'; - import { forceUpdateMetamaskState } from '../../store/actions'; import { submitRequestToBackground } from '../../store/background-connection'; -import { MetaMaskReduxDispatch } from '../../store/store'; import { QuoteRequest } from '../../pages/bridge/types'; +import { MetaMaskReduxDispatch } from '../../store/store'; import { bridgeSlice } from './bridge'; const { @@ -49,6 +48,13 @@ export const setBridgeFeatureFlags = () => { }; }; +export const resetBridgeState = () => { + return async (dispatch: MetaMaskReduxDispatch) => { + dispatch(resetInputFields()); + dispatch(callBridgeControllerMethod(BridgeBackgroundAction.RESET_STATE)); + }; +}; + // User actions export const setFromChain = (chainId: Hex) => { return async (dispatch: MetaMaskReduxDispatch) => { @@ -79,3 +85,13 @@ export const updateQuoteRequestParams = (params: Partial) => { ); }; }; + +export const getBridgeERC20Allowance = async ( + contractAddress: string, + chainId: Hex, +): Promise => { + return await submitRequestToBackground( + BridgeBackgroundAction.GET_BRIDGE_ERC20_ALLOWANCE, + [contractAddress, chainId], + ); +}; diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts index 6b85565c6143..dc9596fcafba 100644 --- a/ui/ducks/bridge/bridge.test.ts +++ b/ui/ducks/bridge/bridge.test.ts @@ -21,6 +21,7 @@ import { resetInputFields, setToChainId, updateQuoteRequestParams, + resetBridgeState, } from './actions'; const middleware = [thunk]; @@ -176,4 +177,35 @@ describe('Ducks - Bridge', () => { ); }); }); + + describe('resetBridgeState', () => { + it('dispatches action to the bridge controller', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockStore = configureMockStore(middleware)( + createBridgeMockStore({}, { fromTokenInputValue: '10' }), + ); + const state = mockStore.getState().bridge; + const mockResetBridgeState = jest.fn(); + setBackgroundConnection({ + [BridgeBackgroundAction.RESET_STATE]: mockResetBridgeState, + } as never); + + mockStore.dispatch(resetBridgeState() as never); + + expect(mockResetBridgeState).toHaveBeenCalledTimes(1); + expect(mockResetBridgeState).toHaveBeenCalledWith( + undefined, + expect.anything(), + ); + const actions = mockStore.getActions(); + expect(actions[0].type).toStrictEqual('bridge/resetInputFields'); + const newState = bridgeReducer(state, actions[0]); + expect(newState).toStrictEqual({ + toChainId: null, + fromToken: null, + toToken: null, + fromTokenInputValue: null, + }); + }); + }); }); diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts index dbb49a20d66b..e39f73f2fa15 100644 --- a/ui/ducks/bridge/selectors.test.ts +++ b/ui/ducks/bridge/selectors.test.ts @@ -6,8 +6,10 @@ import { } from '../../../shared/constants/network'; import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge'; import { mockNetworkState } from '../../../test/stub/networks'; +import mockErc20Erc20Quotes from '../../../test/data/bridge/mock-quotes-erc20-erc20.json'; import { getAllBridgeableNetworks, + getBridgeQuotes, getFromAmount, getFromChain, getFromChains, @@ -491,4 +493,81 @@ describe('Bridge selectors', () => { expect(result).toStrictEqual([{ address: '0x00', symbol: 'TEST' }]); }); }); + + describe('getBridgeQuotes', () => { + it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=5', () => { + const state = createBridgeMockStore( + { extensionConfig: { maxRefreshCount: 5 } }, + { toChainId: '0x1' }, + { + quoteRequest: { insufficientBal: false }, + quotes: mockErc20Erc20Quotes, + quotesFetchStatus: 1, + quotesRefreshCount: 5, + quotesLastFetched: 100, + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + }, + ); + const result = getBridgeQuotes(state as never); + + expect(result).toStrictEqual({ + quotes: mockErc20Erc20Quotes, + quotesLastFetchedMs: 100, + isLoading: false, + quotesRefreshCount: 5, + isQuoteGoingToRefresh: false, + }); + }); + + it('returns quote list and fetch data, insufficientBal=false,quotesRefreshCount=2', () => { + const state = createBridgeMockStore( + { extensionConfig: { maxRefreshCount: 5 } }, + { toChainId: '0x1' }, + { + quoteRequest: { insufficientBal: false }, + quotes: mockErc20Erc20Quotes, + quotesFetchStatus: 1, + quotesRefreshCount: 2, + quotesLastFetched: 100, + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + }, + ); + const result = getBridgeQuotes(state as never); + + expect(result).toStrictEqual({ + quotes: mockErc20Erc20Quotes, + quotesLastFetchedMs: 100, + isLoading: false, + quotesRefreshCount: 2, + isQuoteGoingToRefresh: true, + }); + }); + + it('returns quote list and fetch data, insufficientBal=true', () => { + const state = createBridgeMockStore( + { extensionConfig: { maxRefreshCount: 5 } }, + { toChainId: '0x1' }, + { + quoteRequest: { insufficientBal: true }, + quotes: mockErc20Erc20Quotes, + quotesFetchStatus: 1, + quotesRefreshCount: 1, + quotesLastFetched: 100, + srcTokens: { '0x00': { address: '0x00', symbol: 'TEST' } }, + srcTopAssets: [{ address: '0x00', symbol: 'TEST' }], + }, + ); + const result = getBridgeQuotes(state as never); + + expect(result).toStrictEqual({ + quotes: mockErc20Erc20Quotes, + quotesLastFetchedMs: 100, + isLoading: false, + quotesRefreshCount: 1, + isQuoteGoingToRefresh: false, + }); + }); + }); }); diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index a05640ded5c2..5624a0ec5569 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -26,7 +26,7 @@ import { calcTokenAmount } from '../../../shared/lib/transactions-controller-uti import { RequestStatus } from '../../../app/scripts/controllers/bridge/constants'; import { BridgeState } from './bridge'; -type BridgeAppState = { +export type BridgeAppState = { metamask: NetworkState & { bridgeState: BridgeControllerState } & { useExternalServices: boolean; }; @@ -130,28 +130,51 @@ export const getToToken = ( export const getFromAmount = (state: BridgeAppState): string | null => state.bridge.fromTokenInputValue; -export const getBridgeQuotes = (state: BridgeAppState) => { - return { - quotes: state.metamask.bridgeState.quotes, - quotesLastFetchedMs: state.metamask.bridgeState.quotesLastFetched, - isLoading: - state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, - }; +export const getQuoteRequest = (state: BridgeAppState) => { + const { quoteRequest } = state.metamask.bridgeState; + return quoteRequest; }; +export const getBridgeQuotesConfig = (state: BridgeAppState) => + state.metamask.bridgeState?.bridgeFeatureFlags[ + BridgeFeatureFlagsKey.EXTENSION_CONFIG + ] ?? {}; + +export const getBridgeQuotes = createSelector( + (state: BridgeAppState) => state.metamask.bridgeState.quotes, + (state: BridgeAppState) => state.metamask.bridgeState.quotesLastFetched, + (state: BridgeAppState) => + state.metamask.bridgeState.quotesLoadingStatus === RequestStatus.LOADING, + (state: BridgeAppState) => state.metamask.bridgeState.quotesRefreshCount, + getBridgeQuotesConfig, + getQuoteRequest, + ( + quotes, + quotesLastFetchedMs, + isLoading, + quotesRefreshCount, + { maxRefreshCount }, + { insufficientBal }, + ) => { + return { + quotes, + quotesLastFetchedMs, + isLoading, + quotesRefreshCount, + isQuoteGoingToRefresh: insufficientBal + ? false + : quotesRefreshCount < maxRefreshCount, + }; + }, +); + export const getRecommendedQuote = createSelector( getBridgeQuotes, ({ quotes }) => { - // TODO implement sorting return quotes[0]; }, ); -export const getQuoteRequest = (state: BridgeAppState) => { - const { quoteRequest } = state.metamask.bridgeState; - return quoteRequest; -}; - export const getToAmount = createSelector(getRecommendedQuote, (quote) => quote ? calcTokenAmount( diff --git a/ui/ducks/bridge/utils.ts b/ui/ducks/bridge/utils.ts new file mode 100644 index 000000000000..853c344310fe --- /dev/null +++ b/ui/ducks/bridge/utils.ts @@ -0,0 +1,47 @@ +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { decGWEIToHexWEI } from '../../../shared/modules/conversion.utils'; +import { Numeric } from '../../../shared/modules/Numeric'; +import { TxData } from '../../pages/bridge/types'; +import { getTransaction1559GasFeeEstimates } from '../../pages/swaps/swaps.util'; + +// We don't need to use gas multipliers here because the gasLimit from Bridge API already included it +export const getHexMaxGasLimit = (gasLimit: number) => { + return new Numeric( + new BigNumber(gasLimit).toString(), + 10, + ).toPrefixedHexString() as Hex; +}; +export const getTxGasEstimates = async ({ + networkAndAccountSupports1559, + networkGasFeeEstimates, + txParams, + hexChainId, +}: { + networkAndAccountSupports1559: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + networkGasFeeEstimates: any; + txParams: TxData; + hexChainId: Hex; +}) => { + if (networkAndAccountSupports1559) { + const { estimatedBaseFeeGwei = '0' } = networkGasFeeEstimates; + const hexEstimatedBaseFee = decGWEIToHexWEI(estimatedBaseFeeGwei) as Hex; + const txGasFeeEstimates = await getTransaction1559GasFeeEstimates( + { + ...txParams, + chainId: hexChainId, + gasLimit: txParams.gasLimit?.toString(), + }, + hexEstimatedBaseFee, + hexChainId, + ); + return txGasFeeEstimates; + } + + return { + baseAndPriorityFeePerGas: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }; +}; diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js index 63ff92a11ccc..af456e29acbc 100644 --- a/ui/ducks/metamask/metamask.js +++ b/ui/ducks/metamask/metamask.js @@ -338,6 +338,15 @@ export const getNfts = (state) => { return allNfts?.[selectedAddress]?.[chainId] ?? []; }; +export const getNFTsByChainId = (state, chainId) => { + const { + metamask: { allNfts }, + } = state; + const { address: selectedAddress } = getSelectedInternalAccount(state); + + return allNfts?.[selectedAddress]?.[chainId] ?? []; +}; + export const getNftContracts = (state) => { const { metamask: { allNftContracts }, @@ -360,6 +369,10 @@ export function getConversionRate(state) { ?.conversionRate; } +export function getCurrencyRates(state) { + return state.metamask.currencyRates; +} + export function getSendHexDataFeatureFlagState(state) { return state.metamask.featureFlags.sendHexData; } @@ -457,6 +470,16 @@ export const getGasEstimateTypeByChainId = createSelector( }, ); +/** + * Returns the balances of imported and detected tokens across all accounts and chains. + * + * @param {*} state + * @returns { import('@metamask/assets-controllers').TokenBalancesControllerState['tokenBalances']} + */ +export function getTokenBalances(state) { + return state.metamask.tokenBalances; +} + export const getGasFeeEstimatesByChainId = createSelector( getGasFeeControllerEstimatesByChainId, getTransactionGasFeeEstimatesByChainId, diff --git a/ui/hooks/bridge/useCountdownTimer.test.ts b/ui/hooks/bridge/useCountdownTimer.test.ts index 14ac21d725fd..f2cd1190b1ba 100644 --- a/ui/hooks/bridge/useCountdownTimer.test.ts +++ b/ui/hooks/bridge/useCountdownTimer.test.ts @@ -16,12 +16,22 @@ describe('useCountdownTimer', () => { it('returns time remaining', async () => { const quotesLastFetched = Date.now(); const { result } = renderUseCountdownTimer( - createBridgeMockStore({}, {}, { quotesLastFetched }), + createBridgeMockStore( + {}, + {}, + { + quotesLastFetched, + quotesRefreshCount: 0, + bridgeFeatureFlags: { + extensionConfig: { maxRefreshCount: 5, refreshRate: 40000 }, + }, + }, + ), ); let i = 0; - while (i <= 30) { - const secondsLeft = Math.min(30, 30 - i + 1); + while (i <= 40) { + const secondsLeft = Math.min(41, 40 - i + 2); expect(result.current).toStrictEqual( `0:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`, ); diff --git a/ui/hooks/bridge/useCountdownTimer.ts b/ui/hooks/bridge/useCountdownTimer.ts index 112d35b33f6a..39e7ac9d2eca 100644 --- a/ui/hooks/bridge/useCountdownTimer.ts +++ b/ui/hooks/bridge/useCountdownTimer.ts @@ -1,10 +1,10 @@ import { Duration } from 'luxon'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { getBridgeQuotes } from '../../ducks/bridge/selectors'; -// TODO: Remove restricted import -// eslint-disable-next-line import/no-restricted-paths -import { REFRESH_INTERVAL_MS } from '../../../app/scripts/controllers/bridge/constants'; +import { + getBridgeQuotes, + getBridgeQuotesConfig, +} from '../../ducks/bridge/selectors'; import { SECOND } from '../../../shared/constants/time'; /** @@ -15,13 +15,15 @@ import { SECOND } from '../../../shared/constants/time'; * @returns The formatted remaining time in 'm:ss' format. */ export const useCountdownTimer = () => { - const [timeRemaining, setTimeRemaining] = useState(REFRESH_INTERVAL_MS); const { quotesLastFetchedMs } = useSelector(getBridgeQuotes); + const { refreshRate } = useSelector(getBridgeQuotesConfig); + + const [timeRemaining, setTimeRemaining] = useState(refreshRate); useEffect(() => { if (quotesLastFetchedMs) { setTimeRemaining( - REFRESH_INTERVAL_MS - (Date.now() - quotesLastFetchedMs), + refreshRate - (Date.now() - quotesLastFetchedMs) + SECOND, ); } }, [quotesLastFetchedMs]); diff --git a/ui/hooks/bridge/useLatestBalance.test.ts b/ui/hooks/bridge/useLatestBalance.test.ts index d1186c3eeb91..6d79672e4550 100644 --- a/ui/hooks/bridge/useLatestBalance.test.ts +++ b/ui/hooks/bridge/useLatestBalance.test.ts @@ -61,7 +61,7 @@ describe('useLatestBalance', () => { expect(mockGetBalance).toHaveBeenCalledTimes(1); expect(mockGetBalance).toHaveBeenCalledWith( - '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + '0x0DCD5D886577d5081B0c52e242Ef29E70Be3E7bc', ); expect(mockFetchTokenBalance).toHaveBeenCalledTimes(0); }); diff --git a/ui/hooks/bridge/useLatestBalance.ts b/ui/hooks/bridge/useLatestBalance.ts index dfe868a04090..524d65503249 100644 --- a/ui/hooks/bridge/useLatestBalance.ts +++ b/ui/hooks/bridge/useLatestBalance.ts @@ -1,17 +1,14 @@ import { useSelector } from 'react-redux'; -import { zeroAddress } from 'ethereumjs-util'; -import { Web3Provider } from '@ethersproject/providers'; import { Hex } from '@metamask/utils'; -import { BigNumber } from 'ethers'; import { Numeric } from '../../../shared/modules/Numeric'; import { DEFAULT_PRECISION } from '../useCurrencyDisplay'; -import { fetchTokenBalance } from '../../../shared/lib/token-util'; import { getCurrentChainId, getSelectedInternalAccount, SwapsEthToken, } from '../../selectors'; import { SwapsTokenObject } from '../../../shared/constants/swaps'; +import { calcLatestSrcBalance } from '../../../shared/modules/bridge-utils/balance'; import { useAsyncResult } from '../useAsyncResult'; /** @@ -28,21 +25,25 @@ const useLatestBalance = ( const { address: selectedAddress } = useSelector(getSelectedInternalAccount); const currentChainId = useSelector(getCurrentChainId); - const { value: latestBalance } = useAsyncResult(async () => { - if (token && chainId && currentChainId === chainId) { - if (!token.address || token.address === zeroAddress()) { - const ethersProvider = new Web3Provider(global.ethereumProvider); - return await ethersProvider.getBalance(selectedAddress); - } - return await fetchTokenBalance( - token.address, - selectedAddress, + const { value: latestBalance } = useAsyncResult< + Numeric | undefined + >(async () => { + if (token?.address && chainId && currentChainId === chainId) { + return await calcLatestSrcBalance( global.ethereumProvider, + selectedAddress, + token.address, + chainId, ); } - return undefined; - }, [token, selectedAddress, global.ethereumProvider]); + }, [ + chainId, + currentChainId, + token, + selectedAddress, + global.ethereumProvider, + ]); if (token && !token.decimals) { throw new Error( @@ -55,7 +56,7 @@ const useLatestBalance = ( return { formattedBalance: token && latestBalance - ? Numeric.from(latestBalance.toString(), 10) + ? latestBalance .shiftedBy(tokenDecimals) .round(DEFAULT_PRECISION) .toString() diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx index 99d3064085ea..42e902e29401 100644 --- a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx +++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx @@ -14,6 +14,7 @@ type ArrangeMocksMetamaskStateOverrides = { isUnlocked?: boolean; useExternalServices?: boolean; completedOnboarding?: boolean; + isAccountSyncingReadyToBeDispatched?: boolean; }; const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = { @@ -22,6 +23,7 @@ const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = { isUnlocked: true, useExternalServices: true, completedOnboarding: true, + isAccountSyncingReadyToBeDispatched: true, }; const arrangeMockState = ( @@ -89,6 +91,7 @@ describe('useShouldDispatchProfileSyncing()', () => { 'isUnlocked', 'useExternalServices', 'completedOnboarding', + 'isAccountSyncingReadyToBeDispatched', ] as const; const baseState = { isSignedIn: true, @@ -96,6 +99,7 @@ describe('useShouldDispatchProfileSyncing()', () => { isUnlocked: true, useExternalServices: true, completedOnboarding: true, + isAccountSyncingReadyToBeDispatched: true, }; const failureStateCases: { diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts index 5c073fdf6d94..57820d2e633b 100644 --- a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts +++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts @@ -10,7 +10,10 @@ import { } from '../../../store/actions'; import { selectIsSignedIn } from '../../../selectors/metamask-notifications/authentication'; -import { selectIsProfileSyncingEnabled } from '../../../selectors/metamask-notifications/profile-syncing'; +import { + selectIsAccountSyncingReadyToBeDispatched, + selectIsProfileSyncingEnabled, +} from '../../../selectors/metamask-notifications/profile-syncing'; import { getUseExternalServices } from '../../../selectors'; import { getIsUnlocked, @@ -120,6 +123,9 @@ export function useSetIsProfileSyncingEnabled(): { * @returns a boolean if internally we can perform syncing features or not. */ export const useShouldDispatchProfileSyncing = () => { + const isAccountSyncingReadyToBeDispatched = useSelector( + selectIsAccountSyncingReadyToBeDispatched, + ); const isProfileSyncingEnabled = useSelector(selectIsProfileSyncingEnabled); const basicFunctionality: boolean | undefined = useSelector( getUseExternalServices, @@ -135,7 +141,8 @@ export const useShouldDispatchProfileSyncing = () => { isProfileSyncingEnabled && isUnlocked && isSignedIn && - completedOnboarding, + completedOnboarding && + isAccountSyncingReadyToBeDispatched, ); return shouldDispatchProfileSyncing; diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts new file mode 100644 index 000000000000..9fe819d92171 --- /dev/null +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.test.ts @@ -0,0 +1,226 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { + getCurrentCurrency, + getNetworkConfigurationsByChainId, + getCrossChainTokenExchangeRates, + getCrossChainMetaMaskCachedBalances, +} from '../selectors'; +import { getCurrencyRates } from '../ducks/metamask/metamask'; +import { + FormattedTokensWithBalances, + useAccountTotalCrossChainFiatBalance, +} from './useAccountTotalCrossChainFiatBalance'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../selectors', () => ({ + getCurrentCurrency: jest.fn(), + getNetworkConfigurationsByChainId: jest.fn(), + getCrossChainTokenExchangeRates: jest.fn(), + getCrossChainMetaMaskCachedBalances: jest.fn(), +})); +jest.mock('../ducks/metamask/metamask', () => ({ + getCurrencyRates: jest.fn(), +})); + +const mockGetCurrencyRates = getCurrencyRates as jest.Mock; +const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock; +const mockGetNetworkConfigurationsByChainId = + getNetworkConfigurationsByChainId as unknown as jest.Mock; +const mockGetCrossChainTokenExchangeRates = + getCrossChainTokenExchangeRates as jest.Mock; +const mockGetCrossChainMetaMaskCachedBalances = + getCrossChainMetaMaskCachedBalances as jest.Mock; + +const mockUseTokenBalances = jest.fn().mockReturnValue({ + tokenBalances: { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x2f18e6', + '0x6B175474E89094C44Da98b954EedeAC495271d0F': '0x378afc9a77b47a30', + }, + }, + }, +}); +jest.mock('./useTokenBalances', () => ({ + useTokenBalances: () => mockUseTokenBalances(), + stringifyBalance: jest.fn(), +})); + +const mockCurrencyRates = { + ETH: { + conversionDate: 1732040829.246, + conversionRate: 3124.56, + usdConversionRate: 3124.56, + }, + LineaETH: { + conversionDate: 1732040829.246, + conversionRate: 3124.56, + usdConversionRate: 3124.56, + }, +}; + +const mockNetworkConfigs = { + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, + '0xe708': { + blockExplorerUrls: ['https://lineascan.build'], + chainId: '0xe708', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Linea', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'linea-mainnet', + type: 'infura', + url: 'https://linea-mainnet.infura.io/v3/{infuraProjectId}', + }, + ], + }, +}; + +const mockCrossChainTokenExchangeRates = { + '0x1': { + '0x0000000000000000000000000000000000000000': 1.0000131552270237, + '0x4d224452801ACEd8B2F0aebE155379bb5D594381': 0.0003643652288147761, + '0x6982508145454Ce325dDbE47a25d4ec3d2311933': 6.62249784302e-9, + '0x6B175474E89094C44Da98b954EedeAC495271d0F': 0.00031961862176734744, + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 0.00031993824038911484, + '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84': 0.9994154684043188, + }, + '0xe708': { + '0x0000000000000000000000000000000000000000': 0.9999084951480334, + }, +}; + +const mockCachedBalances = { + '0x1': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': '0x4e2adedda15fd6', + }, + '0xe708': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': '0x4e2adedda15fd6', + }, +}; + +describe('useAccountTotalCrossChainFiatBalance', () => { + beforeEach(() => { + mockGetCurrencyRates.mockReturnValue(mockCurrencyRates); + mockGetCurrentCurrency.mockReturnValue('usd'); + mockGetNetworkConfigurationsByChainId.mockReturnValue(mockNetworkConfigs); + mockGetCrossChainTokenExchangeRates.mockReturnValue( + mockCrossChainTokenExchangeRates, + ); + mockGetCrossChainMetaMaskCachedBalances.mockReturnValue(mockCachedBalances); + + jest.clearAllMocks(); + }); + it('should return totalFiatBalance successfully for eth and linea', async () => { + const testAccount = { + id: '7d3a1213-c465-4995-b42a-85e2ccfd2f22', + address: '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + }; + const testFormattedTokensWithBalances = [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + symbol: 'USDC', + decimals: 6, + balance: '3086566', + string: '3.08656', + image: '', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + symbol: 'DAI', + decimals: 18, + balance: '4002288959235586608', + string: '4.00228', + image: '', + }, + ], + }, + { + chainId: '0xe708', + tokensWithBalances: [], + }, + ]; + + const expectedResult = { + tokenFiatBalancesCrossChains: [ + { + chainId: '0x1', + nativeFiatValue: '68.75', + tokenFiatBalances: ['3.09', '4'], + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: '3086566', + decimals: 6, + image: '', + string: '3.08656', + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + balance: '4002288959235586608', + decimals: 18, + image: '', + string: '4.00228', + symbol: 'DAI', + }, + ], + }, + { + chainId: '0xe708', + nativeFiatValue: '68.75', + tokenFiatBalances: [], + tokensWithBalances: [], + }, + ], + totalFiatBalance: '144.59', + }; + + let result; + await act(async () => { + result = renderHook(() => + useAccountTotalCrossChainFiatBalance( + testAccount, + testFormattedTokensWithBalances as FormattedTokensWithBalances[], + ), + ); + }); + + expect((result as unknown as Record).result.current).toEqual( + expectedResult, + ); + }); +}); diff --git a/ui/hooks/useAccountTotalCrossChainFiatBalance.ts b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts new file mode 100644 index 000000000000..d63328e4fbcf --- /dev/null +++ b/ui/hooks/useAccountTotalCrossChainFiatBalance.ts @@ -0,0 +1,117 @@ +import { shallowEqual, useSelector } from 'react-redux'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { + getCurrentCurrency, + getNetworkConfigurationsByChainId, + getCrossChainTokenExchangeRates, + getCrossChainMetaMaskCachedBalances, +} from '../selectors'; +import { + getValueFromWeiHex, + sumDecimals, +} from '../../shared/modules/conversion.utils'; +import { getCurrencyRates } from '../ducks/metamask/metamask'; +import { getTokenFiatAmount } from '../helpers/utils/token-util'; +import { TokenWithBalance } from '../components/app/assets/asset-list/asset-list'; + +type AddressBalances = { + [address: string]: number; +}; + +export type Balances = { + [id: string]: AddressBalances; +}; + +export type FormattedTokensWithBalances = { + chainId: string; + tokensWithBalances: TokenWithBalance[]; +}; + +export const useAccountTotalCrossChainFiatBalance = ( + account: { address: string }, + formattedTokensWithBalancesPerChain: FormattedTokensWithBalances[], +) => { + const allNetworks = useSelector(getNetworkConfigurationsByChainId); + const currencyRates = useSelector(getCurrencyRates); + const currentCurrency = useSelector(getCurrentCurrency); + + const crossChainContractRates = useSelector( + getCrossChainTokenExchangeRates, + shallowEqual, + ); + const crossChainCachedBalances: Balances = useSelector( + getCrossChainMetaMaskCachedBalances, + ); + const mergedCrossChainRates: Balances = { + ...crossChainContractRates, // todo add confirmation exchange rates? + }; + + const tokenFiatBalancesCrossChains = formattedTokensWithBalancesPerChain.map( + (singleChainTokenBalances) => { + const { tokensWithBalances } = singleChainTokenBalances; + const matchedChainSymbol = + allNetworks[singleChainTokenBalances.chainId as `0x${string}`] + .nativeCurrency; + const conversionRate = + currencyRates?.[matchedChainSymbol]?.conversionRate; + const tokenFiatBalances = tokensWithBalances.map((token) => { + const tokenExchangeRate = + mergedCrossChainRates?.[singleChainTokenBalances.chainId]?.[ + toChecksumAddress(token.address) + ]; + const totalFiatValue = getTokenFiatAmount( + tokenExchangeRate, + conversionRate, + currentCurrency, + token.string, + token.symbol, + false, + false, + ); + + return totalFiatValue; + }); + + const balanceCached = + crossChainCachedBalances?.[singleChainTokenBalances.chainId]?.[ + account?.address + ] ?? 0; + const nativeFiatValue = getValueFromWeiHex({ + value: balanceCached, + toCurrency: currentCurrency, + conversionRate, + numberOfDecimals: 2, + }); + return { + ...singleChainTokenBalances, + tokenFiatBalances, + nativeFiatValue, + }; + }, + ); + + const finalTotal = tokenFiatBalancesCrossChains.reduce( + (accumulator, currentValue) => { + const tmpCurrentValueFiatBalances: string[] = + currentValue.tokenFiatBalances.filter( + (value): value is string => value !== undefined, + ); + const totalFiatBalance = sumDecimals( + currentValue.nativeFiatValue, + ...tmpCurrentValueFiatBalances, + ); + + const totalAsNumber = totalFiatBalance.toNumber + ? totalFiatBalance.toNumber() + : Number(totalFiatBalance); + + return accumulator + totalAsNumber; + }, + 0, + ); + + return { + totalFiatBalance: finalTotal.toString(10), + tokenFiatBalancesCrossChains, + }; +}; diff --git a/ui/hooks/useAccountTotalFiatBalance.js b/ui/hooks/useAccountTotalFiatBalance.js index 7b4a4675225a..aa1f906473ef 100644 --- a/ui/hooks/useAccountTotalFiatBalance.js +++ b/ui/hooks/useAccountTotalFiatBalance.js @@ -22,7 +22,7 @@ import { import { formatCurrency } from '../helpers/utils/confirm-tx.util'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { roundToDecimalPlacesRemovingExtraZeroes } from '../helpers/utils/util'; -import { useTokenTracker } from './useTokenTracker'; +import { useTokenTracker } from './useTokenBalances'; export const useAccountTotalFiatBalance = ( account, @@ -54,10 +54,11 @@ export const useAccountTotalFiatBalance = ( const primaryTokenImage = useSelector(getNativeCurrencyImage); const nativeCurrency = useSelector(getNativeCurrency); - const { loading, tokensWithBalances } = useTokenTracker({ + const loading = false; + const { tokensWithBalances } = useTokenTracker({ + chainId: currentChainId, tokens, address: account?.address, - includeFailedTokens: true, hideZeroBalanceTokens: shouldHideZeroBalanceTokens, }); diff --git a/ui/hooks/useAccountTotalFiatBalance.test.js b/ui/hooks/useAccountTotalFiatBalance.test.js index 9fb1227367e1..6ac93cd08e33 100644 --- a/ui/hooks/useAccountTotalFiatBalance.test.js +++ b/ui/hooks/useAccountTotalFiatBalance.test.js @@ -14,35 +14,6 @@ const mockAccount = createMockInternalAccount({ address: '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da', }); -jest.mock('./useTokenTracker', () => { - return { - useTokenTracker: () => ({ - loading: false, - tokensWithBalances: [ - { - address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - balance: '48573', - balanceError: null, - decimals: 6, - image: undefined, - isERC721: undefined, - string: '0.04857', - symbol: 'USDC', - }, - { - address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', - symbol: 'YFI', - balance: '1409247882142934', - decimals: 18, - string: '0.001409247882142934', - balanceError: null, - }, - ], - error: null, - }), - }; -}); - const renderUseAccountTotalFiatBalance = (address) => { const state = { ...mockState, @@ -78,7 +49,7 @@ const renderUseAccountTotalFiatBalance = (address) => { }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - detectedTokens: { + allTokens: { '0x1': { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { @@ -96,6 +67,14 @@ const renderUseAccountTotalFiatBalance = (address) => { ], }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', + }, + }, + }, }, }; @@ -122,8 +101,6 @@ describe('useAccountTotalFiatBalance', () => { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', balance: '48573', - image: undefined, - isERC721: undefined, decimals: 6, string: 0.04857, balanceError: null, diff --git a/ui/hooks/useAccountTrackerPolling.ts b/ui/hooks/useAccountTrackerPolling.ts new file mode 100644 index 000000000000..cc7f9aee3818 --- /dev/null +++ b/ui/hooks/useAccountTrackerPolling.ts @@ -0,0 +1,51 @@ +import { useSelector } from 'react-redux'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../selectors'; +import { + accountTrackerStartPolling, + accountTrackerStopPollingByPollingToken, +} from '../store/actions'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; +import useMultiPolling from './useMultiPolling'; + +const useAccountTrackerPolling = () => { + // Selectors to determine polling input + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const currentChainId = useSelector(getCurrentChainId); + const currentNetwork = networkConfigurations[currentChainId]; + const currentRpcEndpoint = + currentNetwork.rpcEndpoints[currentNetwork.defaultRpcEndpointIndex]; + + const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); + const availableNetworkClientIds = Object.values(networkConfigurations).map( + (networkConfiguration) => + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].networkClientId, + ); + const canStartPolling = completedOnboarding && isUnlocked; + const portfolioViewNetworks = canStartPolling + ? availableNetworkClientIds + : []; + const nonPortfolioViewNetworks = canStartPolling + ? [currentRpcEndpoint.networkClientId] + : []; + + const networkArrayToPollFor = process.env.PORTFOLIO_VIEW + ? portfolioViewNetworks + : nonPortfolioViewNetworks; + + useMultiPolling({ + startPolling: accountTrackerStartPolling, + stopPollingByPollingToken: accountTrackerStopPollingByPollingToken, + input: networkArrayToPollFor, + }); +}; + +export default useAccountTrackerPolling; diff --git a/ui/hooks/useCurrencyRatePolling.ts b/ui/hooks/useCurrencyRatePolling.ts index e7ad21adedf5..34772a94a501 100644 --- a/ui/hooks/useCurrencyRatePolling.ts +++ b/ui/hooks/useCurrencyRatePolling.ts @@ -7,14 +7,20 @@ import { currencyRateStartPolling, currencyRateStopPollingByPollingToken, } from '../store/actions'; -import { getCompletedOnboarding } from '../ducks/metamask/metamask'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; import usePolling from './usePolling'; const useCurrencyRatePolling = () => { const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const enabled = completedOnboarding && isUnlocked && useCurrencyRateCheck; + const nativeCurrencies = [ ...new Set( Object.values(networkConfigurations).map((n) => n.nativeCurrency), @@ -25,7 +31,7 @@ const useCurrencyRatePolling = () => { startPolling: currencyRateStartPolling, stopPollingByPollingToken: currencyRateStopPollingByPollingToken, input: nativeCurrencies, - enabled: useCurrencyRateCheck && completedOnboarding, + enabled, }); }; diff --git a/ui/hooks/useGetFormattedTokensPerChain.test.ts b/ui/hooks/useGetFormattedTokensPerChain.test.ts new file mode 100644 index 000000000000..973a16b0e648 --- /dev/null +++ b/ui/hooks/useGetFormattedTokensPerChain.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-dom/test-utils'; +import { getAllTokens, getCurrentChainId } from '../selectors'; +import { useGetFormattedTokensPerChain } from './useGetFormattedTokensPerChain'; +import { stringifyBalance } from './useTokenBalances'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn((selector) => selector()), +})); + +jest.mock('../selectors', () => ({ + getCurrentChainId: jest.fn(), + getAllTokens: jest.fn(), +})); + +const mockGetAllTokens = getAllTokens as jest.Mock; +const mockGetCurrentChainId = getCurrentChainId as jest.Mock; + +const mockUseTokenBalances = jest.fn().mockReturnValue({ + tokenBalances: { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x2f18e6', + '0x6B175474E89094C44Da98b954EedeAC495271d0F': '0x378afc9a77b47a30', + }, + }, + }, +}); +jest.mock('./useTokenBalances', () => ({ + useTokenBalances: () => mockUseTokenBalances(), + stringifyBalance: jest.fn(), +})); + +const allTokens = { + '0x1': { + '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5': [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'Crypto.com', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 6, + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + aggregators: [ + 'Metamask', + 'Aave', + 'Bancor', + 'CMC', + 'Crypto.com', + '1inch', + 'PMM', + 'Sushiswap', + 'Zerion', + 'Lifi', + 'Socket', + 'Squid', + 'Openswap', + 'UniswapLabs', + 'Coinmarketcap', + ], + decimals: 18, + symbol: 'DAI', + }, + ], + }, +}; + +describe('useGetFormattedTokensPerChain', () => { + beforeEach(() => { + mockGetAllTokens.mockReturnValue(allTokens); + mockGetCurrentChainId.mockReturnValue('0x1'); + + jest.clearAllMocks(); + }); + it('should tokensWithBalances for an array of chainIds', async () => { + (stringifyBalance as jest.Mock).mockReturnValueOnce(10.5); + (stringifyBalance as jest.Mock).mockReturnValueOnce(13); + const allChainIDs = ['0x1']; + const isTokenNetworkFilterEqualCurrentNetwork = true; + const shouldHideZeroBalanceTokens = true; + const testAccount = { + id: '7d3a1213-c465-4995-b42a-85e2ccfd2f22', + address: '0xac7985f2e57609bdd7ad3003e4be868d83e4b6d5', + options: {}, + methods: [ + 'personal_sign', + 'eth_sign', + 'eth_signTransaction', + 'eth_signTypedData_v1', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + ], + }; + + const expectedResult = { + formattedTokensWithBalancesPerChain: [ + { + chainId: '0x1', + tokensWithBalances: [ + { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + balance: '3086566', + decimals: 6, + string: 10.5, + symbol: 'USDC', + }, + { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + balance: '4002288959235586608', + decimals: 18, + string: 13, + symbol: 'DAI', + }, + ], + }, + ], + }; + + let result; + await act(async () => { + result = renderHook(() => + useGetFormattedTokensPerChain( + testAccount, + shouldHideZeroBalanceTokens, + isTokenNetworkFilterEqualCurrentNetwork, + allChainIDs, + ), + ); + }); + + expect((result as unknown as Record).result.current).toEqual( + expectedResult, + ); + }); +}); diff --git a/ui/hooks/useGetFormattedTokensPerChain.ts b/ui/hooks/useGetFormattedTokensPerChain.ts new file mode 100644 index 000000000000..a69f5be9e1e0 --- /dev/null +++ b/ui/hooks/useGetFormattedTokensPerChain.ts @@ -0,0 +1,77 @@ +import { useSelector } from 'react-redux'; +import { BN } from 'bn.js'; +import { Token } from '@metamask/assets-controllers'; +import { getAllTokens, getCurrentChainId } from '../selectors'; +import { hexToDecimal } from '../../shared/modules/conversion.utils'; + +import { TokenWithBalance } from '../components/multichain/asset-picker-amount/asset-picker-modal/types'; +import { stringifyBalance, useTokenBalances } from './useTokenBalances'; + +type AddressMapping = { + [chainId: string]: { + [tokenAddress: string]: string; + }; +}; + +type TokenBalancesMapping = { + [address: string]: AddressMapping; +}; + +export const useGetFormattedTokensPerChain = ( + account: { address: string }, + shouldHideZeroBalanceTokens: boolean, + shouldGetTokensPerCurrentChain: boolean, + allChainIDs: string[], +) => { + const currentChainId = useSelector(getCurrentChainId); + + const importedTokens = useSelector(getAllTokens); // returns the tokens only when they are imported + const currentTokenBalances: { tokenBalances: TokenBalancesMapping } = + useTokenBalances({ + chainIds: allChainIDs as `0x${string}`[], + }); + + // We will calculate aggregated balance only after the user imports the tokens to the wallet + // we need to format the balances we get from useTokenBalances and match them with symbol and decimals we get from getAllTokens + const networksToFormat = shouldGetTokensPerCurrentChain + ? [currentChainId] + : allChainIDs; + const formattedTokensWithBalancesPerChain = networksToFormat.map( + (singleChain) => { + const tokens = importedTokens?.[singleChain]?.[account?.address] ?? []; + + const tokensWithBalances = tokens.reduce( + (acc: TokenWithBalance[], token: Token) => { + const hexBalance = + currentTokenBalances.tokenBalances[account.address]?.[ + singleChain + ]?.[token.address] ?? '0x0'; + if (hexBalance !== '0x0' || !shouldHideZeroBalanceTokens) { + const decimalBalance = hexToDecimal(hexBalance); + acc.push({ + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + balance: decimalBalance, + string: stringifyBalance( + new BN(decimalBalance), + new BN(token.decimals), + ), + }); + } + return acc; + }, + [], + ); + + return { + chainId: singleChain, + tokensWithBalances, + }; + }, + ); + + return { + formattedTokensWithBalancesPerChain, + }; +}; diff --git a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx index ffd664612a02..e46eff925e50 100644 --- a/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx +++ b/ui/hooks/useMultichainAccountTotalFiatBalance.test.tsx @@ -15,31 +15,21 @@ const mockTokenBalances = [ balance: '48573', balanceError: null, decimals: 6, - image: undefined, - isERC721: undefined, - string: '0.04857', + string: 0.04857, symbol: 'USDC', + tokenFiatAmount: '0.05', }, { address: '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', symbol: 'YFI', balance: '1409247882142934', decimals: 18, - string: '0.001409247882142934', + string: 0.00141, balanceError: null, + tokenFiatAmount: '7.52', }, ]; -jest.mock('./useTokenTracker', () => { - return { - useTokenTracker: () => ({ - loading: false, - tokensWithBalances: mockTokenBalances, - error: null, - }), - }; -}); - const mockAccount = createMockInternalAccount({ name: 'Account 1', address: '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da', @@ -104,7 +94,7 @@ const renderUseMultichainAccountTotalFiatBalance = ( }, ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), - detectedTokens: { + allTokens: { '0x1': { '0x0836f5ed6b62baf60706fe3adc0ff0fd1df833da': [ { @@ -122,6 +112,14 @@ const renderUseMultichainAccountTotalFiatBalance = ( ], }, }, + tokenBalances: { + [mockAccount.address]: { + [CHAIN_IDS.MAINNET]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0xBDBD', + '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e': '0x501B4176A64D6', + }, + }, + }, }, }; diff --git a/ui/hooks/useMultichainAccountTotalFiatBalance.ts b/ui/hooks/useMultichainAccountTotalFiatBalance.ts index 9e807be41ea5..335b8399c6d5 100644 --- a/ui/hooks/useMultichainAccountTotalFiatBalance.ts +++ b/ui/hooks/useMultichainAccountTotalFiatBalance.ts @@ -31,9 +31,9 @@ export const useMultichainAccountTotalFiatBalance = ( tokensWithBalances: { address: string; symbol: string; - decimals: string; - isERC721: boolean; - image: string; + decimals: number; + isERC721?: boolean; + image?: string; }[]; totalWeiBalance?: string; totalBalance?: string; diff --git a/ui/hooks/useTokenBalances.ts b/ui/hooks/useTokenBalances.ts new file mode 100644 index 000000000000..8d3a078f8d07 --- /dev/null +++ b/ui/hooks/useTokenBalances.ts @@ -0,0 +1,114 @@ +import { useSelector } from 'react-redux'; +import BN from 'bn.js'; +import { Token } from '@metamask/assets-controllers'; +import { Hex } from '@metamask/utils'; +import { getNetworkConfigurationsByChainId } from '../selectors'; +import { + tokenBalancesStartPolling, + tokenBalancesStopPollingByPollingToken, +} from '../store/actions'; +import { getTokenBalances } from '../ducks/metamask/metamask'; +import { hexToDecimal } from '../../shared/modules/conversion.utils'; +import useMultiPolling from './useMultiPolling'; + +export const useTokenBalances = ({ chainIds }: { chainIds?: Hex[] } = {}) => { + const tokenBalances = useSelector(getTokenBalances); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + + useMultiPolling({ + startPolling: tokenBalancesStartPolling, + stopPollingByPollingToken: tokenBalancesStopPollingByPollingToken, + input: chainIds ?? Object.keys(networkConfigurations), + }); + + return { tokenBalances }; +}; + +// This hook is designed for backwards compatibility with `ui/hooks/useTokenTracker.js` +// and the github.com/MetaMask/eth-token-tracker library. It replaces RPC calls with +// reading state from `TokenBalancesController`. It should not be used in new code. +// Instead, prefer to use `useTokenBalances` directly, or compose higher level hooks from it. +export const useTokenTracker = ({ + chainId, + tokens, + address, + hideZeroBalanceTokens, +}: { + chainId: Hex; + tokens: Token[]; + address: Hex; + hideZeroBalanceTokens?: boolean; +}) => { + const { tokenBalances } = useTokenBalances({ chainIds: [chainId] }); + + const tokensWithBalances = tokens.reduce((acc, token) => { + const hexBalance = + tokenBalances[address]?.[chainId]?.[token.address as Hex] ?? '0x0'; + if (hexBalance !== '0x0' || !hideZeroBalanceTokens) { + const decimalBalance = hexToDecimal(hexBalance); + acc.push({ + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + balance: decimalBalance, + balanceError: null, + string: stringifyBalance( + new BN(decimalBalance), + new BN(token.decimals), + ), + }); + } + return acc; + }, [] as (Token & { balance: string; string: string; balanceError: unknown })[]); + + return { + tokensWithBalances, + }; +}; + +// From https://github.com/MetaMask/eth-token-tracker/blob/main/lib/util.js +// Ensures backwards compatibility with display formatting. +export function stringifyBalance( + balance: BN, + bnDecimals: BN, + balanceDecimals = 5, +) { + if (balance.eq(new BN(0))) { + return '0'; + } + + const decimals = parseInt(bnDecimals.toString(), 10); + if (decimals === 0) { + return balance.toString(); + } + + let bal = balance.toString(); + let len = bal.length; + let decimalIndex = len - decimals; + let prefix = ''; + + if (decimalIndex <= 0) { + while (prefix.length <= decimalIndex * -1) { + prefix += '0'; + len += 1; + } + bal = prefix + bal; + decimalIndex = 1; + } + + const whole = bal.substr(0, len - decimals); + + if (balanceDecimals === 0) { + return whole; + } + + const fractional = bal.substr(decimalIndex, balanceDecimals); + if (/0+$/u.test(fractional)) { + let withOnlySigZeroes = bal.substr(decimalIndex).replace(/0+$/u, ''); + if (withOnlySigZeroes.length > 0) { + withOnlySigZeroes = `.${withOnlySigZeroes}`; + } + return `${whole}${withOnlySigZeroes}`; + } + return `${whole}.${fractional}`; +} diff --git a/ui/hooks/useTokenDetectionPolling.ts b/ui/hooks/useTokenDetectionPolling.ts new file mode 100644 index 000000000000..d2e08d01892d --- /dev/null +++ b/ui/hooks/useTokenDetectionPolling.ts @@ -0,0 +1,39 @@ +import { useSelector } from 'react-redux'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, + getUseTokenDetection, +} from '../selectors'; +import { + tokenDetectionStartPolling, + tokenDetectionStopPollingByPollingToken, +} from '../store/actions'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; +import useMultiPolling from './useMultiPolling'; + +const useTokenDetectionPolling = () => { + const useTokenDetection = useSelector(getUseTokenDetection); + const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); + const currentChainId = useSelector(getCurrentChainId); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + + const enabled = completedOnboarding && isUnlocked && useTokenDetection; + + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + + useMultiPolling({ + startPolling: tokenDetectionStartPolling, + stopPollingByPollingToken: tokenDetectionStopPollingByPollingToken, + input: enabled ? [chainIds] : [], + }); + + return {}; +}; + +export default useTokenDetectionPolling; diff --git a/ui/hooks/useTokenListPolling.test.ts b/ui/hooks/useTokenListPolling.test.ts new file mode 100644 index 000000000000..001dca71c80e --- /dev/null +++ b/ui/hooks/useTokenListPolling.test.ts @@ -0,0 +1,131 @@ +import { renderHookWithProvider } from '../../test/lib/render-helpers'; +import { + tokenListStartPolling, + tokenListStopPollingByPollingToken, +} from '../store/actions'; +import useTokenListPolling from './useTokenListPolling'; + +let mockPromises: Promise[]; + +jest.mock('../store/actions', () => ({ + tokenListStartPolling: jest.fn().mockImplementation((input) => { + const promise = Promise.resolve(`${input}_token`); + mockPromises.push(promise); + return promise; + }), + tokenListStopPollingByPollingToken: jest.fn(), +})); + +describe('useTokenListPolling', () => { + beforeEach(() => { + mockPromises = []; + jest.clearAllMocks(); + }); + + it('should poll the selected network when enabled, and stop on dismount', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useExternalServices: true, + useTokenDetection: true, + selectedNetworkClientId: 'selectedNetworkClientId', + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + rpcEndpoints: [ + { + networkClientId: 'selectedNetworkClientId', + }, + ], + }, + }, + }, + }; + + const { unmount } = renderHookWithProvider( + () => useTokenListPolling(), + state, + ); + + // Should poll each chain + await Promise.all(mockPromises); + expect(tokenListStartPolling).toHaveBeenCalledTimes(1); + expect(tokenListStartPolling).toHaveBeenCalledWith('0x1'); + + // Stop polling on dismount + unmount(); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(1); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledWith( + '0x1_token', + ); + }); + + it('should not poll before onboarding is completed', async () => { + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: false, + useExternalServices: true, + useTokenDetection: true, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenListPolling(), state); + + await Promise.all(mockPromises); + expect(tokenListStartPolling).toHaveBeenCalledTimes(0); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when locked', async () => { + const state = { + metamask: { + isUnlocked: false, + completedOnboarding: true, + useExternalServices: true, + useTokenDetection: true, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenListPolling(), state); + + await Promise.all(mockPromises); + expect(tokenListStartPolling).toHaveBeenCalledTimes(0); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); + + it('should not poll when disabled', async () => { + // disabled when detection, petnames, and simulations are all disabled + const state = { + metamask: { + isUnlocked: true, + completedOnboarding: true, + useExternalServices: true, + useTokenDetection: false, + useTransactionSimulations: false, + preferences: { + petnamesEnabled: false, + }, + networkConfigurationsByChainId: { + '0x1': {}, + '0x89': {}, + }, + }, + }; + + renderHookWithProvider(() => useTokenListPolling(), state); + + await Promise.all(mockPromises); + expect(tokenListStartPolling).toHaveBeenCalledTimes(0); + expect(tokenListStopPollingByPollingToken).toHaveBeenCalledTimes(0); + }); +}); diff --git a/ui/hooks/useTokenListPolling.ts b/ui/hooks/useTokenListPolling.ts new file mode 100644 index 000000000000..7f7de517c304 --- /dev/null +++ b/ui/hooks/useTokenListPolling.ts @@ -0,0 +1,49 @@ +import { useSelector } from 'react-redux'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, + getPetnamesEnabled, + getUseExternalServices, + getUseTokenDetection, + getUseTransactionSimulations, +} from '../selectors'; +import { + tokenListStartPolling, + tokenListStopPollingByPollingToken, +} from '../store/actions'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; +import useMultiPolling from './useMultiPolling'; + +const useTokenListPolling = () => { + const currentChainId = useSelector(getCurrentChainId); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const useTokenDetection = useSelector(getUseTokenDetection); + const useTransactionSimulations = useSelector(getUseTransactionSimulations); + const petnamesEnabled = useSelector(getPetnamesEnabled); + const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); + const useExternalServices = useSelector(getUseExternalServices); + + const enabled = + completedOnboarding && + isUnlocked && + useExternalServices && + (useTokenDetection || petnamesEnabled || useTransactionSimulations); + + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + + useMultiPolling({ + startPolling: tokenListStartPolling, + stopPollingByPollingToken: tokenListStopPollingByPollingToken, + input: enabled ? chainIds : [], + }); + + return {}; +}; + +export default useTokenListPolling; diff --git a/ui/hooks/useTokenRatesPolling.ts b/ui/hooks/useTokenRatesPolling.ts index 41c1c8793b97..37864ec89b82 100644 --- a/ui/hooks/useTokenRatesPolling.ts +++ b/ui/hooks/useTokenRatesPolling.ts @@ -1,5 +1,6 @@ import { useSelector } from 'react-redux'; import { + getCurrentChainId, getMarketData, getNetworkConfigurationsByChainId, getTokenExchangeRates, @@ -10,10 +11,17 @@ import { tokenRatesStartPolling, tokenRatesStopPollingByPollingToken, } from '../store/actions'; +import { + getCompletedOnboarding, + getIsUnlocked, +} from '../ducks/metamask/metamask'; import useMultiPolling from './useMultiPolling'; -const useTokenRatesPolling = ({ chainIds }: { chainIds?: string[] } = {}) => { +const useTokenRatesPolling = () => { // Selectors to determine polling input + const completedOnboarding = useSelector(getCompletedOnboarding); + const isUnlocked = useSelector(getIsUnlocked); + const currentChainId = useSelector(getCurrentChainId); const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); @@ -22,12 +30,16 @@ const useTokenRatesPolling = ({ chainIds }: { chainIds?: string[] } = {}) => { const tokensMarketData = useSelector(getTokensMarketData); const marketData = useSelector(getMarketData); + const enabled = completedOnboarding && isUnlocked && useCurrencyRateCheck; + + const chainIds = process.env.PORTFOLIO_VIEW + ? Object.keys(networkConfigurations) + : [currentChainId]; + useMultiPolling({ startPolling: tokenRatesStartPolling, stopPollingByPollingToken: tokenRatesStopPollingByPollingToken, - input: useCurrencyRateCheck - ? chainIds ?? Object.keys(networkConfigurations) - : [], + input: enabled ? chainIds : [], }); return { diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index 4b551b318b62..b008f2fdaf7d 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -357,6 +357,15 @@ export function useTransactionDisplayData(transactionGroup) { category = TransactionGroupCategory.send; title = t('send'); subtitle = t('toAddress', [shortenAddress(recipientAddress)]); + } else if (type === TransactionType.bridgeApproval) { + title = t('bridgeApproval'); + category = TransactionGroupCategory.approval; + title = t('bridgeApproval', [primaryTransaction.sourceTokenSymbol]); + subtitle = origin; + subtitleContainsOrigin = true; + primarySuffix = primaryTransaction.sourceTokenSymbol; // TODO this will be undefined right now + } else if (type === TransactionType.bridge) { + title = t('bridge'); } else { dispatch( captureSingleException( diff --git a/ui/pages/asset/asset.scss b/ui/pages/asset/asset.scss index 8d682beae58b..d6f6a3d39d9b 100644 --- a/ui/pages/asset/asset.scss +++ b/ui/pages/asset/asset.scss @@ -1,4 +1,4 @@ -@use "design-system"; +@use 'design-system'; .asset { &__container { @@ -42,5 +42,19 @@ } } -.chart-up { stroke: var(--color-success-default); } -.chart-down { stroke: var(--color-error-default); } +.chart-up { + stroke: var(--color-success-default); +} + +.chart-down { + stroke: var(--color-error-default); +} + +.asset-page__spending-caps { + text-decoration: none; + + &:hover { + color: var(--color-primary-alternative); + text-decoration: underline; + } +} diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap index b5ebc0a83eb6..8a5c60e340cb 100644 --- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap +++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap @@ -254,6 +254,36 @@ exports[`AssetPage should render a native asset 1`] = `
+
+

+ Token details +

+
+
+

+ Spending caps +

+ + Edit in Portfolio + +
+
+
@@ -555,59 +585,84 @@ exports[`AssetPage should render an ERC20 asset without prices 1`] = ` Token details
-

- Contract address -

-
- + +
+ 0x30937...C4936 +
+
+ + +
+
+
+
+
+

+ Token decimal +

+

+ 18 +

+
-
-

- Token decimal + Spending caps

-

- 18 -

+ Edit in Portfolio +
@@ -1038,59 +1093,84 @@ exports[`AssetPage should render an ERC20 token with prices 1`] = ` Token details
-

- Contract address -

-
- + +
+ 0xe4246...85f55 +
+
+ + +
+
+
+
+
+

+ Token decimal +

+

+ 18 +

+
- -

- Token decimal + Spending caps

-

- 18 -

+ Edit in Portfolio +
diff --git a/ui/pages/asset/components/asset-page.test.tsx b/ui/pages/asset/components/asset-page.test.tsx index 35721a30a1c2..5df516184004 100644 --- a/ui/pages/asset/components/asset-page.test.tsx +++ b/ui/pages/asset/components/asset-page.test.tsx @@ -13,6 +13,12 @@ import { setBackgroundConnection } from '../../../store/background-connection'; import { mockNetworkState } from '../../../../test/stub/networks'; import AssetPage from './asset-page'; +jest.mock('../../../store/actions', () => ({ + ...jest.requireActual('../../../store/actions'), + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), +})); + // Mock the price chart jest.mock('react-chartjs-2', () => ({ Line: () => null })); @@ -135,7 +141,9 @@ describe('AssetPage', () => { balance: { value: '0', display: '0', + fiat: '', }, + decimals: 18, } as const; const token = { @@ -148,6 +156,7 @@ describe('AssetPage', () => { balance: { value: '0', display: '0', + fiat: '', }, } as const; diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index c70b60169edb..818ceb792ec3 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; @@ -6,8 +6,11 @@ import { EthMethod } from '@metamask/keyring-api'; import { isEqual } from 'lodash'; import { getCurrentCurrency, + getDataCollectionForMarketing, getIsBridgeChain, getIsSwapsChain, + getMetaMetricsId, + getParticipateInMetaMetrics, getSelectedInternalAccount, getSwapsDefaultToken, getTokensMarketData, @@ -24,6 +27,7 @@ import { Box, ButtonIcon, ButtonIconSize, + ButtonLink, IconName, Text, } from '../../../components/component-library'; @@ -42,6 +46,7 @@ import { getConversionRate } from '../../../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils'; import CoinButtons from '../../../components/app/wallet-overview/coin-buttons'; import { getIsNativeTokenBuyable } from '../../../ducks/ramps'; +import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; import AssetChart from './chart/asset-chart'; import TokenButtons from './token-buttons'; @@ -110,6 +115,10 @@ const AssetPage = ({ account.methods.includes(EthMethod.SignTransaction) || account.methods.includes(EthMethod.SignUserOperation); + const isMetaMetricsEnabled = useSelector(getParticipateInMetaMetrics); + const isMarketingEnabled = useSelector(getDataCollectionForMarketing); + const metaMetricsId = useSelector(getMetaMetricsId); + const { chainId, type, symbol, name, image, balance } = asset; const address = @@ -124,6 +133,20 @@ const AssetPage = ({ ? conversionRate * marketData.price : undefined; + const portfolioSpendingCapsUrl = useMemo( + () => + getPortfolioUrl( + '', + 'asset_page', + metaMetricsId, + isMetaMetricsEnabled, + isMarketingEnabled, + account.address, + 'spending-caps', + ), + [account.address, isMarketingEnabled, isMetaMetricsEnabled, metaMetricsId], + ); + return ( {type === AssetType.native ? ( ) : ( - {type === AssetType.token && ( + {[AssetType.token, AssetType.native].includes(type) && ( {t('tokenDetails')} - {renderRow( - t('contractAddress'), - , - )} - {asset.decimals !== undefined && - renderRow(t('tokenDecimal'), {asset.decimals})} - {asset.aggregators && asset.aggregators?.length > 0 && ( + {type === AssetType.token && ( - , + )} + - {t('tokenList')} - - {asset.aggregators?.join(', ')} + {asset.decimals !== undefined && + renderRow( + t('tokenDecimal'), + {asset.decimals}, + )} + {asset.aggregators && asset.aggregators.length > 0 && ( + + + {t('tokenList')} + + {asset.aggregators.join(', ')} + + )} + )} + {renderRow( + t('spendingCaps'), + + {t('editInPortfolio')} + , + )} )} diff --git a/ui/pages/asset/useHistoricalPrices.ts b/ui/pages/asset/useHistoricalPrices.ts index e4b28add0bc7..febf99a9daed 100644 --- a/ui/pages/asset/useHistoricalPrices.ts +++ b/ui/pages/asset/useHistoricalPrices.ts @@ -31,8 +31,8 @@ export const useHistoricalPrices = ({ const [loading, setLoading] = useState(chainSupported); const [data, setData] = useState({}); - if (chainSupported) { - useEffect(() => { + useEffect(() => { + if (chainSupported) { setLoading(true); fetchWithCache({ url: `https://price.api.cx.metamask.io/v1/chains/${chainId}/historical-prices/${address}?vsCurrency=${currency}&timePeriod=${timeRange}`, @@ -59,7 +59,11 @@ export const useHistoricalPrices = ({ setData({ prices, edges }); setLoading(false); }); - }, [chainId, address, currency, timeRange]); - } + } else { + setData({}); + setLoading(false); + } + }, [chainSupported, chainId, address, currency, timeRange]); + return { loading, data }; }; diff --git a/ui/pages/asset/util.test.ts b/ui/pages/asset/util.test.ts new file mode 100644 index 000000000000..ecf4f2de5381 --- /dev/null +++ b/ui/pages/asset/util.test.ts @@ -0,0 +1,66 @@ +import { Token } from '@metamask/assets-controllers'; +import { findAssetByAddress } from './util'; + +describe('findAssetByAddress', () => { + const mockTokens: Record = { + '0x1': [ + { address: '0xabc', decimals: 18, symbol: 'ABC', name: 'Token ABC' }, + { address: '0xdef', decimals: 18, symbol: 'DEF', name: 'Token DEF' }, + ], + '0x2': [ + { address: '0x123', decimals: 18, symbol: 'XYZ', name: 'Token XYZ' }, + { address: '0x456', decimals: 18, symbol: 'LMN', name: 'Token LMN' }, + ], + }; + + it('should return null and log error when chainId is not provided', () => { + console.error = jest.fn(); + expect(findAssetByAddress(mockTokens, '0xabc')).toBeNull(); + expect(console.error).toHaveBeenCalledWith('Chain ID is required.'); + }); + + it('should return null and log warning when no tokens are found for chainId', () => { + console.warn = jest.fn(); + expect(findAssetByAddress(mockTokens, '0x123', '0x99')).toBeNull(); + expect(console.warn).toHaveBeenCalledWith( + 'No tokens found for chainId: 0x99', + ); + }); + + it('should return undefined if address is not provided and no token without address is found', () => { + expect(findAssetByAddress(mockTokens, undefined, '0x1')).toBeNull(); + }); + + it('should return the token without address if address is not provided and a token without address exists', () => { + const tokensWithNullAddress: Record = { + '0x1': [ + { address: '', decimals: 18, symbol: 'NULL', name: 'Token NULL' }, + ], + }; + expect( + findAssetByAddress(tokensWithNullAddress, undefined, '0x1'), + ).toBeNull(); + }); + + it('should return the correct token when address and chainId are provided', () => { + expect(findAssetByAddress(mockTokens, '0xabc', '0x1')).toEqual({ + address: '0xabc', + decimals: 18, + symbol: 'ABC', + name: 'Token ABC', + }); + }); + + it('should return undefined if no token matches the provided address on the chainId', () => { + expect(findAssetByAddress(mockTokens, '0x999', '0x1')).toBeUndefined(); + }); + + it('should be case insensitive when matching addresses', () => { + expect(findAssetByAddress(mockTokens, '0xABC', '0x1')).toEqual({ + address: '0xabc', + decimals: 18, + symbol: 'ABC', + name: 'Token ABC', + }); + }); +}); diff --git a/ui/pages/asset/util.ts b/ui/pages/asset/util.ts index 824040fa6560..479c69015f35 100644 --- a/ui/pages/asset/util.ts +++ b/ui/pages/asset/util.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_CHAIN_IDS } from '@metamask/assets-controllers'; +import { SUPPORTED_CHAIN_IDS, Token } from '@metamask/assets-controllers'; /** Formats a datetime in a short human readable format like 'Feb 8, 12:11 PM' */ export const getShortDateFormatter = () => @@ -64,3 +64,31 @@ export const chainSupportsPricing = (chainId: `0x${string}`) => /** The opacity components should set during transition */ export const loadingOpacity = 0.2; + +export const findAssetByAddress = ( + data: Record, + address?: string, + chainId?: string, +): Token | undefined | null => { + if (!chainId) { + console.error('Chain ID is required.'); + return null; + } + + const tokens = data[chainId]; + + if (!tokens) { + console.warn(`No tokens found for chainId: ${chainId}`); + return null; + } + + if (!address) { + console.warn(`No token found for address: ${address}`); + return null; + } + + return tokens.find( + (token) => + token.address && token.address.toLowerCase() === address.toLowerCase(), + ); +}; diff --git a/ui/pages/bridge/bridge.util.test.ts b/ui/pages/bridge/bridge.util.test.ts index d8cba6c109b0..30514dbf7f96 100644 --- a/ui/pages/bridge/bridge.util.test.ts +++ b/ui/pages/bridge/bridge.util.test.ts @@ -172,22 +172,27 @@ describe('Bridge utils', () => { (fetchWithCache as jest.Mock).mockResolvedValue( mockBridgeQuotesNativeErc20, ); + const { signal } = new AbortController(); - const result = await fetchBridgeQuotes({ - walletAddress: '0x123', - srcChainId: 1, - destChainId: 10, - srcTokenAddress: zeroAddress(), - destTokenAddress: zeroAddress(), - srcTokenAmount: '20000', - slippage: 0.5, - }); + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: zeroAddress(), + destTokenAddress: zeroAddress(), + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + ); expect(fetchWithCache).toHaveBeenCalledWith({ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', fetchOptions: { method: 'GET', headers: { 'X-Client-Id': 'extension' }, + signal, }, cacheOptions: { cacheRefreshTime: 0 }, functionName: 'fetchBridgeQuotes', @@ -202,22 +207,27 @@ describe('Bridge utils', () => { { ...mockBridgeQuotesErc20Erc20[0], approval: null }, { ...mockBridgeQuotesErc20Erc20[0], trade: null }, ]); + const { signal } = new AbortController(); - const result = await fetchBridgeQuotes({ - walletAddress: '0x123', - srcChainId: 1, - destChainId: 10, - srcTokenAddress: zeroAddress(), - destTokenAddress: zeroAddress(), - srcTokenAmount: '20000', - slippage: 0.5, - }); + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: zeroAddress(), + destTokenAddress: zeroAddress(), + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + ); expect(fetchWithCache).toHaveBeenCalledWith({ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', fetchOptions: { method: 'GET', headers: { 'X-Client-Id': 'extension' }, + signal, }, cacheOptions: { cacheRefreshTime: 0 }, functionName: 'fetchBridgeQuotes', @@ -251,22 +261,27 @@ describe('Bridge utils', () => { }, }, ]); + const { signal } = new AbortController(); - const result = await fetchBridgeQuotes({ - walletAddress: '0x123', - srcChainId: 1, - destChainId: 10, - srcTokenAddress: zeroAddress(), - destTokenAddress: zeroAddress(), - srcTokenAmount: '20000', - slippage: 0.5, - }); + const result = await fetchBridgeQuotes( + { + walletAddress: '0x123', + srcChainId: 1, + destChainId: 10, + srcTokenAddress: zeroAddress(), + destTokenAddress: zeroAddress(), + srcTokenAmount: '20000', + slippage: 0.5, + }, + signal, + ); expect(fetchWithCache).toHaveBeenCalledWith({ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false', fetchOptions: { method: 'GET', headers: { 'X-Client-Id': 'extension' }, + signal, }, cacheOptions: { cacheRefreshTime: 0 }, functionName: 'fetchBridgeQuotes', diff --git a/ui/pages/bridge/bridge.util.ts b/ui/pages/bridge/bridge.util.ts index f154b7e62b19..577b1827b160 100644 --- a/ui/pages/bridge/bridge.util.ts +++ b/ui/pages/bridge/bridge.util.ts @@ -1,4 +1,6 @@ +import { Contract } from '@ethersproject/contracts'; import { Hex, add0x } from '@metamask/utils'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import { BridgeFeatureFlagsKey, BridgeFeatureFlags, @@ -8,6 +10,8 @@ import { import { BRIDGE_API_BASE_URL, BRIDGE_CLIENT_ID, + ETH_USDT_ADDRESS, + METABRIDGE_ETHEREUM_ADDRESS, } from '../../../shared/constants/bridge'; import { MINUTE } from '../../../shared/constants/time'; import fetchWithCache from '../../../shared/lib/fetch-with-cache'; @@ -26,6 +30,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { REFRESH_INTERVAL_MS } from '../../../app/scripts/controllers/bridge/constants'; +import { CHAIN_IDS } from '../../../shared/constants/network'; import { BridgeAsset, BridgeFlag, @@ -136,6 +141,7 @@ export async function fetchBridgeTokens( // Returns a list of bridge tx quotes export async function fetchBridgeQuotes( request: QuoteRequest, + signal: AbortSignal, ): Promise { const queryParams = new URLSearchParams({ walletAddress: request.walletAddress, @@ -151,7 +157,11 @@ export async function fetchBridgeQuotes( const url = `${BRIDGE_API_BASE_URL}/getQuote?${queryParams}`; const quotes = await fetchWithCache({ url, - fetchOptions: { method: 'GET', headers: CLIENT_ID_HEADER }, + fetchOptions: { + method: 'GET', + headers: CLIENT_ID_HEADER, + signal, + }, cacheOptions: { cacheRefreshTime: 0 }, functionName: 'fetchBridgeQuotes', }); @@ -180,3 +190,22 @@ export async function fetchBridgeQuotes( }); return filteredQuotes; } +/** + * A function to return the txParam data for setting allowance to 0 for USDT on Ethereum + * + * @returns The txParam data that will reset allowance to 0, combine it with the approval tx params received from Bridge API + */ +export const getEthUsdtResetData = () => { + const UsdtContractInterface = new Contract(ETH_USDT_ADDRESS, abiERC20) + .interface; + const data = UsdtContractInterface.encodeFunctionData('approve', [ + METABRIDGE_ETHEREUM_ADDRESS, + '0', + ]); + + return data; +}; + +export const isEthUsdt = (chainId: Hex, address: string) => + chainId === CHAIN_IDS.MAINNET && + address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); diff --git a/ui/pages/bridge/hooks/useAddToken.ts b/ui/pages/bridge/hooks/useAddToken.ts new file mode 100644 index 000000000000..597149b16e49 --- /dev/null +++ b/ui/pages/bridge/hooks/useAddToken.ts @@ -0,0 +1,84 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { NetworkConfiguration } from '@metamask/network-controller'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { QuoteResponse } from '../types'; +import { + getNetworkConfigurationsByChainId, + getSelectedNetworkClientId, +} from '../../../selectors'; +import { FEATURED_RPCS } from '../../../../shared/constants/network'; +import { addToken, addNetwork } from '../../../store/actions'; + +export default function useAddToken() { + const dispatch = useDispatch(); + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const sourceNetworkClientId = useSelector(getSelectedNetworkClientId); + + const addSourceToken = (quoteResponse: QuoteResponse) => { + const { + address, + decimals, + symbol, + icon: image, + } = quoteResponse.quote.srcAsset; + dispatch( + addToken({ + address, + decimals, + symbol, + image, + networkClientId: sourceNetworkClientId, + }), + ); + }; + + const addDestToken = async (quoteResponse: QuoteResponse) => { + // Look up the destination chain + const hexDestChainId = new Numeric(quoteResponse.quote.destChainId, 10) + .toPrefixedHexString() + .toLowerCase() as `0x${string}`; + const foundDestNetworkConfig: NetworkConfiguration | undefined = + networkConfigurations[hexDestChainId]; + let addedDestNetworkConfig: NetworkConfiguration | undefined; + + // If user has not added the network in MetaMask, add it for them silently + if (!foundDestNetworkConfig) { + const featuredRpc = FEATURED_RPCS.find( + (rpc) => rpc.chainId === hexDestChainId, + ); + if (!featuredRpc) { + throw new Error('No featured RPC found'); + } + addedDestNetworkConfig = (await dispatch( + addNetwork(featuredRpc), + )) as unknown as NetworkConfiguration; + } + + const destNetworkConfig = foundDestNetworkConfig || addedDestNetworkConfig; + if (!destNetworkConfig) { + throw new Error('No destination network configuration found'); + } + + // Add the token after network is guaranteed to exist + const rpcEndpointIndex = destNetworkConfig.defaultRpcEndpointIndex; + const destNetworkClientId = + destNetworkConfig.rpcEndpoints[rpcEndpointIndex].networkClientId; + const { + address, + decimals, + symbol, + icon: image, + } = quoteResponse.quote.destAsset; + await dispatch( + addToken({ + address, + decimals, + symbol, + image, + networkClientId: destNetworkClientId, + }), + ); + }; + + return { addSourceToken, addDestToken }; +} diff --git a/ui/pages/bridge/hooks/useHandleApprovalTx.ts b/ui/pages/bridge/hooks/useHandleApprovalTx.ts new file mode 100644 index 000000000000..67f2abf67e7e --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleApprovalTx.ts @@ -0,0 +1,94 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { TxData, QuoteResponse, FeeType } from '../types'; +import { isEthUsdt, getEthUsdtResetData } from '../bridge.util'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { ETH_USDT_ADDRESS } from '../../../../shared/constants/bridge'; +import { getBridgeERC20Allowance } from '../../../ducks/bridge/actions'; +import useHandleTx from './useHandleTx'; + +export default function useHandleApprovalTx() { + const { handleTx } = useHandleTx(); + + const handleEthUsdtAllowanceReset = async ({ + approval, + quoteResponse, + hexChainId, + }: { + approval: TxData; + quoteResponse: QuoteResponse; + hexChainId: Hex; + }) => { + const allowance = new BigNumber( + await getBridgeERC20Allowance(ETH_USDT_ADDRESS, hexChainId), + ); + + // quote.srcTokenAmount is actually after the fees + // so we need to add fees back in for total allowance to give + const sentAmount = new BigNumber(quoteResponse.quote.srcTokenAmount) + .plus(quoteResponse.quote.feeData[FeeType.METABRIDGE].amount) + .toString(); + + const shouldResetApproval = allowance.lt(sentAmount) && allowance.gt(0); + + if (shouldResetApproval) { + const resetData = getEthUsdtResetData(); + const txParams = { + ...approval, + data: resetData, + }; + + await handleTx({ + txType: TransactionType.bridgeApproval, + txParams, + swapsOptions: { + hasApproveTx: true, + meta: { + type: TransactionType.bridgeApproval, + }, + }, + }); + } + }; + + const handleApprovalTx = async ({ + approval, + quoteResponse, + }: { + approval: TxData; + quoteResponse: QuoteResponse; + }) => { + const hexChainId = new Numeric( + approval.chainId, + 10, + ).toPrefixedHexString() as `0x${string}`; + + // On Ethereum, we need to reset the allowance to 0 for USDT first if we need to set a new allowance + // https://www.google.com/url?q=https://docs.unizen.io/trade-api/before-you-get-started/token-allowance-management-for-non-updatable-allowance-tokens&sa=D&source=docs&ust=1727386175513609&usg=AOvVaw3Opm6BSJeu7qO0Ve5iLTOh + if (isEthUsdt(hexChainId, quoteResponse.quote.srcAsset.address)) { + await handleEthUsdtAllowanceReset({ + approval, + quoteResponse, + hexChainId, + }); + } + + const txMeta = await handleTx({ + txType: TransactionType.bridgeApproval, + txParams: approval, + swapsOptions: { + hasApproveTx: true, + meta: { + type: TransactionType.bridgeApproval, + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + }, + }, + }); + + return txMeta.id; + }; + return { + handleApprovalTx, + }; +} diff --git a/ui/pages/bridge/hooks/useHandleBridgeTx.ts b/ui/pages/bridge/hooks/useHandleBridgeTx.ts new file mode 100644 index 000000000000..22b2a74fa077 --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleBridgeTx.ts @@ -0,0 +1,48 @@ +import { BigNumber } from 'bignumber.js'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Numeric } from '../../../../shared/modules/Numeric'; +import { FeeType, QuoteResponse } from '../types'; +import useHandleTx from './useHandleTx'; + +export default function useHandleBridgeTx() { + const { handleTx } = useHandleTx(); + + const handleBridgeTx = async ({ + quoteResponse, + approvalTxId, + }: { + quoteResponse: QuoteResponse; + approvalTxId: string | undefined; + }) => { + const sentAmount = new BigNumber(quoteResponse.quote.srcTokenAmount).plus( + quoteResponse.quote.feeData[FeeType.METABRIDGE].amount, + ); + const sentAmountDec = new Numeric(sentAmount, 10) + .shiftedBy(quoteResponse.quote.srcAsset.decimals) + .toString(); + + const txMeta = await handleTx({ + txType: TransactionType.bridge, + txParams: quoteResponse.trade, + swapsOptions: { + hasApproveTx: Boolean(quoteResponse?.approval), + meta: { + // estimatedBaseFee: decEstimatedBaseFee, + // swapMetaData, + type: TransactionType.bridge, + sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, + destinationTokenSymbol: quoteResponse.quote.destAsset.symbol, + destinationTokenDecimals: quoteResponse.quote.destAsset.decimals, + destinationTokenAddress: quoteResponse.quote.destAsset.address, + approvalTxId, + // this is the decimal (non atomic) amount (not USD value) of source token to swap + swapTokenValue: sentAmountDec, + }, + }, + }); + + return txMeta.id; + }; + + return { handleBridgeTx }; +} diff --git a/ui/pages/bridge/hooks/useHandleTx.ts b/ui/pages/bridge/hooks/useHandleTx.ts new file mode 100644 index 000000000000..a4cbf631c338 --- /dev/null +++ b/ui/pages/bridge/hooks/useHandleTx.ts @@ -0,0 +1,79 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { useDispatch, useSelector } from 'react-redux'; +import { + forceUpdateMetamaskState, + addTransactionAndWaitForPublish, +} from '../../../store/actions'; +import { + getHexMaxGasLimit, + getTxGasEstimates, +} from '../../../ducks/bridge/utils'; +import { getGasFeeEstimates } from '../../../ducks/metamask/metamask'; +import { checkNetworkAndAccountSupports1559 } from '../../../selectors'; +import { ChainId } from '../types'; +import { Numeric } from '../../../../shared/modules/Numeric'; + +export default function useHandleTx() { + const dispatch = useDispatch(); + const networkAndAccountSupports1559 = useSelector( + checkNetworkAndAccountSupports1559, + ); + const networkGasFeeEstimates = useSelector(getGasFeeEstimates); + + const handleTx = async ({ + txType, + txParams, + swapsOptions, + }: { + txType: TransactionType.bridgeApproval | TransactionType.bridge; + txParams: { + chainId: ChainId; + to: string; + from: string; + value: string; + data: string; + gasLimit: number | null; + }; + swapsOptions: { + hasApproveTx: boolean; + meta: Partial; + }; + }) => { + const hexChainId = new Numeric( + txParams.chainId, + 10, + ).toPrefixedHexString() as `0x${string}`; + + const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates({ + networkAndAccountSupports1559, + networkGasFeeEstimates, + txParams, + hexChainId, + }); + const maxGasLimit = getHexMaxGasLimit(txParams.gasLimit ?? 0); + + const finalTxParams = { + ...txParams, + chainId: hexChainId, + gasLimit: maxGasLimit, + gas: maxGasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + }; + + const txMeta = await addTransactionAndWaitForPublish(finalTxParams, { + requireApproval: false, + type: txType, + swaps: swapsOptions, + }); + + await forceUpdateMetamaskState(dispatch); + + return txMeta; + }; + + return { handleTx }; +} diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx new file mode 100644 index 000000000000..20f471b1065b --- /dev/null +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.test.tsx @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { MemoryRouter, useHistory } from 'react-router-dom'; +import { createBridgeMockStore } from '../../../../test/jest/mock-store'; +import * as actions from '../../../store/actions'; +import * as selectors from '../../../selectors'; +import { + DummyQuotesNoApproval, + DummyQuotesWithApproval, +} from '../../../../test/data/bridge/dummy-quotes'; +import useSubmitBridgeTransaction from './useSubmitBridgeTransaction'; + +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useHistory: jest.fn().mockImplementation(original.useHistory), + }; +}); + +jest.mock('../../../ducks/bridge/utils', () => ({ + ...jest.requireActual('../../../ducks/bridge/utils'), + getTxGasEstimates: jest.fn(() => ({ + baseAndPriorityFeePerGas: '0', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + })), +})); + +jest.mock('../../../store/actions', () => { + const original = jest.requireActual('../../../store/actions'); + return { + ...original, + addTransactionAndWaitForPublish: jest.fn(), + addToken: jest.fn().mockImplementation(original.addToken), + addNetwork: jest.fn().mockImplementation(original.addNetwork), + }; +}); + +jest.mock('../../../selectors', () => { + const original = jest.requireActual('../../../selectors'); + return { + ...original, + getIsBridgeEnabled: () => true, + getIsBridgeChain: () => true, + checkNetworkAndAccountSupports1559: () => true, + getSelectedNetworkClientId: () => 'mainnet', + getNetworkConfigurationsByChainId: jest.fn(() => ({ + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + '0xa4b1': { + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + })), + }; +}); + +const middleware = [thunk]; + +const makeMockStore = () => { + const store = configureMockStore(middleware)( + createBridgeMockStore( + {}, + {}, + {}, + { + gasFeeEstimates: { + high: { + maxWaitTimeEstimate: 30000, + minWaitTimeEstimate: 15000, + suggestedMaxFeePerGas: '14.226414113', + suggestedMaxPriorityFeePerGas: '2', + }, + }, + useExternalServices: true, + }, + ), + ); + return store; +}; + +const makeWrapper = + (store: ReturnType) => + ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); + }; + +describe('ui/pages/bridge/hooks/useSubmitBridgeTransaction', () => { + describe('submitBridgeTransaction', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('executes bridge transaction', async () => { + // Setup + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockAddTransactionAndWaitForPublish).toHaveBeenLastCalledWith( + { + chainId: '0x1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0x33403', + gasLimit: '0x33403', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { + approvalTxId: 'txMetaId-01', + destinationTokenAddress: + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + destinationTokenDecimals: 6, + destinationTokenSymbol: 'USDC', + sourceTokenSymbol: 'USDC', + swapTokenValue: '11', + type: 'bridge', + }, + }, + type: 'bridge', + }, + ); + }); + it('executes approval transaction if it exists', async () => { + // Setup + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockAddTransactionAndWaitForPublish).toHaveBeenNthCalledWith( + 1, + { + chainId: '0x1', + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000a7d8c0', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0xdc1d', + gasLimit: '0xdc1d', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { sourceTokenSymbol: 'USDC', type: 'bridgeApproval' }, + }, + type: 'bridgeApproval', + }, + ); + expect(mockAddTransactionAndWaitForPublish).toHaveBeenNthCalledWith( + 2, + { + chainId: '0x1', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000a7d8c000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000a4b1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000af88d065e77c8cc2239327c5edb3a432268e58310000000000000000000000000000000000000000000000000000000000a660c6000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000177fa000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000000902340ab8fc3119af1d016a0eec5fe6ef47965741f6f7a4734bf784bf3ae3f2452a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000a660c60000a4b10008df3abdeb853d66fefedfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000740cfc1bc02079862368cb4eea1332bd9f2dfa925fc757fd51e40919859b87ca031a2a12d67e4ca4ba67d52b59114b3e18c1e8c839ae015112af82e92251db701b', + from: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452', + gas: '0x33403', + gasLimit: '0x33403', + maxFeePerGas: '0x1036640', + maxPriorityFeePerGas: '0x0', + to: '0x0439e60F02a8900a951603950d8D4527f400C3f1', + value: '0x00', + }, + { + requireApproval: false, + swaps: { + hasApproveTx: true, + meta: { + approvalTxId: 'txMetaId-01', + destinationTokenAddress: + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + destinationTokenDecimals: 6, + destinationTokenSymbol: 'USDC', + sourceTokenSymbol: 'USDC', + swapTokenValue: '11', + type: 'bridge', + }, + }, + type: 'bridge', + }, + ); + }); + it('adds source token if it not the native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).toHaveBeenCalledWith({ + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + networkClientId: 'mainnet', + symbol: 'USDC', + }); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('does not add source token if source token is native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).not.toHaveBeenCalled(); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('adds dest token if it not the native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).toHaveBeenCalledWith({ + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + decimals: 6, + image: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + symbol: 'USDC', + }); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('does not add dest token if dest token is native gas token', async () => { + // Setup + const store = makeMockStore(); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + (actions.addToken as jest.Mock).mockImplementation( + () => async () => ({}), + ); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesNoApproval.OP_0_005_ETH_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addToken).not.toHaveBeenCalled(); + + // Reset + const originalAddToken = jest.requireActual( + '../../../store/actions', + ).addToken; + (actions.addToken as jest.Mock).mockImplementation(originalAddToken); + }); + it('adds dest network if it does not exist', async () => { + // Setup + const store = makeMockStore(); + + const mockAddTransactionAndWaitForPublish = jest.fn(() => { + return { + id: 'txMetaId-01', + }; + }); + // For some reason, setBackgroundConnection does not work, gets hung up on the promise, so mock this way instead + (actions.addTransactionAndWaitForPublish as jest.Mock).mockImplementation( + mockAddTransactionAndWaitForPublish, + ); + const mockedGetNetworkConfigurationsByChainId = + // @ts-expect-error this is a jest mock + selectors.getNetworkConfigurationsByChainId as jest.Mock; + mockedGetNetworkConfigurationsByChainId.mockImplementationOnce(() => ({ + '0x1': { + blockExplorerUrls: ['https://etherscan.io'], + chainId: '0x1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: 'mainnet', + type: 'infura', + url: 'https://mainnet.infura.io/v3/infuraProjectId', + }, + ], + }, + })); + (actions.addNetwork as jest.Mock).mockImplementationOnce( + () => async () => ({ + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + networkClientId: '3725601d-f497-43aa-9afa-97c26e9033a3', + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/infuraProjectId', + }, + ], + }), + ); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(actions.addNetwork).toHaveBeenCalledWith({ + blockExplorerUrls: ['https://explorer.arbitrum.io'], + chainId: '0xa4b1', + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + rpcEndpoints: [ + { + type: 'custom', + url: 'https://arbitrum-mainnet.infura.io/v3/undefined', + }, + ], + }); + }); + it('routes to activity tab', async () => { + const store = makeMockStore(); + + const mockHistory = { + push: jest.fn(), + }; + (useHistory as jest.Mock).mockImplementationOnce(() => mockHistory); + const { result } = renderHook(() => useSubmitBridgeTransaction(), { + wrapper: makeWrapper(store), + }); + + // Execute + await result.current.submitBridgeTransaction( + DummyQuotesWithApproval.ETH_11_USDC_TO_ARB[0] as any, + ); + + // Assert + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + }); +}); diff --git a/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts new file mode 100644 index 000000000000..db3b1c86ca06 --- /dev/null +++ b/ui/pages/bridge/hooks/useSubmitBridgeTransaction.ts @@ -0,0 +1,49 @@ +import { useDispatch } from 'react-redux'; +import { zeroAddress } from 'ethereumjs-util'; +import { useHistory } from 'react-router-dom'; +import { QuoteResponse } from '../types'; +import { DEFAULT_ROUTE } from '../../../helpers/constants/routes'; +import { setDefaultHomeActiveTabName } from '../../../store/actions'; +import useAddToken from './useAddToken'; +import useHandleApprovalTx from './useHandleApprovalTx'; +import useHandleBridgeTx from './useHandleBridgeTx'; + +export default function useSubmitBridgeTransaction() { + const history = useHistory(); + const dispatch = useDispatch(); + const { addSourceToken, addDestToken } = useAddToken(); + const { handleApprovalTx } = useHandleApprovalTx(); + const { handleBridgeTx } = useHandleBridgeTx(); + + const submitBridgeTransaction = async (quoteResponse: QuoteResponse) => { + // Execute transaction(s) + let approvalTxId: string | undefined; + if (quoteResponse?.approval) { + approvalTxId = await handleApprovalTx({ + approval: quoteResponse.approval, + quoteResponse, + }); + } + + await handleBridgeTx({ + quoteResponse, + approvalTxId, + }); + + // Add tokens if not the native gas token + if (quoteResponse.quote.srcAsset.address !== zeroAddress()) { + addSourceToken(quoteResponse); + } + if (quoteResponse.quote.destAsset.address !== zeroAddress()) { + await addDestToken(quoteResponse); + } + + // Route user to activity tab on Home page + await dispatch(setDefaultHomeActiveTabName('activity')); + history.push(DEFAULT_ROUTE); + }; + + return { + submitBridgeTransaction, + }; +} diff --git a/ui/pages/bridge/index.test.tsx b/ui/pages/bridge/index.test.tsx index 0d0d4c21c71f..7d5f813513c5 100644 --- a/ui/pages/bridge/index.test.tsx +++ b/ui/pages/bridge/index.test.tsx @@ -8,6 +8,7 @@ import { renderWithProvider, MOCKS, CONSTANTS } from '../../../test/jest'; import { createBridgeMockStore } from '../../../test/jest/mock-store'; import CrossChainSwap from '.'; +const mockResetBridgeState = jest.fn(); const middleware = [thunk]; setBackgroundConnection({ resetPostFetchState: jest.fn(), @@ -24,6 +25,7 @@ setBackgroundConnection({ .mockResolvedValue({ chainId: '0x1' }), setBridgeFeatureFlags: jest.fn(), selectSrcNetwork: jest.fn(), + resetState: () => mockResetBridgeState(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); @@ -73,5 +75,6 @@ describe('Bridge', () => { expect(getByText('Bridge')).toBeInTheDocument(); expect(container).toMatchSnapshot(); + expect(mockResetBridgeState).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/pages/bridge/index.tsx b/ui/pages/bridge/index.tsx index e81b20670011..2c9f082519e9 100644 --- a/ui/pages/bridge/index.tsx +++ b/ui/pages/bridge/index.tsx @@ -24,13 +24,16 @@ import { Header, } from '../../components/multichain/pages/page'; import { getProviderConfig } from '../../ducks/metamask/metamask'; -import { resetInputFields, setFromChain } from '../../ducks/bridge/actions'; +import { resetBridgeState, setFromChain } from '../../ducks/bridge/actions'; +import { useSwapsFeatureFlags } from '../swaps/hooks/useSwapsFeatureFlags'; import PrepareBridgePage from './prepare/prepare-bridge-page'; import { BridgeCTAButton } from './prepare/bridge-cta-button'; const CrossChainSwap = () => { const t = useContext(I18nContext); + // Load swaps feature flags so that we can use smart transactions + useSwapsFeatureFlags(); useBridging(); const history = useHistory(); @@ -45,11 +48,23 @@ const CrossChainSwap = () => { isBridgeEnabled && providerConfig && dispatch(setFromChain(providerConfig.chainId)); + }, [isBridgeChain, isBridgeEnabled, providerConfig]); + + const resetControllerAndInputStates = async () => { + await dispatch(resetBridgeState()); + }; + + useEffect(() => { + // Reset controller and inputs before unloading the page + resetControllerAndInputStates(); + + window.addEventListener('beforeunload', resetControllerAndInputStates); return () => { - dispatch(resetInputFields()); + window.removeEventListener('beforeunload', resetControllerAndInputStates); + resetControllerAndInputStates(); }; - }, [isBridgeChain, isBridgeEnabled, providerConfig]); + }, []); const redirectToDefaultRoute = async () => { history.push({ @@ -58,6 +73,7 @@ const CrossChainSwap = () => { }); dispatch(clearSwapsState()); await dispatch(resetBackgroundSwapsState()); + await resetControllerAndInputStates(); }; return ( diff --git a/ui/pages/bridge/prepare/bridge-cta-button.tsx b/ui/pages/bridge/prepare/bridge-cta-button.tsx index 28a1a2c1fbd6..06d784f2e0ea 100644 --- a/ui/pages/bridge/prepare/bridge-cta-button.tsx +++ b/ui/pages/bridge/prepare/bridge-cta-button.tsx @@ -1,19 +1,23 @@ import React, { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Button } from '../../../components/component-library'; import { getBridgeQuotes, getFromAmount, getFromChain, getFromToken, + getRecommendedQuote, getToAmount, getToChain, getToToken, } from '../../../ducks/bridge/selectors'; import { useI18nContext } from '../../../hooks/useI18nContext'; +import useSubmitBridgeTransaction from '../hooks/useSubmitBridgeTransaction'; export const BridgeCTAButton = () => { + const dispatch = useDispatch(); const t = useI18nContext(); + const fromToken = useSelector(getFromToken); const toToken = useSelector(getToToken); @@ -24,6 +28,9 @@ export const BridgeCTAButton = () => { const toAmount = useSelector(getToAmount); const { isLoading } = useSelector(getBridgeQuotes); + const quoteResponse = useSelector(getRecommendedQuote); + + const { submitBridgeTransaction } = useSubmitBridgeTransaction(); const isTxSubmittable = fromToken && toToken && fromChain && toChain && fromAmount && toAmount; @@ -52,7 +59,7 @@ export const BridgeCTAButton = () => { data-testid="bridge-cta-button" onClick={() => { if (isTxSubmittable) { - // dispatch tx submission + dispatch(submitBridgeTransaction(quoteResponse)); } }} disabled={!isTxSubmittable} diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx index 1ae0b59f3757..aba35d5b89be 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.test.tsx @@ -18,6 +18,10 @@ describe('PrepareBridgePage', () => { global.ethereumProvider = provider as any; }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render the component, with initial state', async () => { const mockStore = createBridgeMockStore( { @@ -54,9 +58,17 @@ describe('PrepareBridgePage', () => { { srcNetworkAllowlist: [CHAIN_IDS.MAINNET, CHAIN_IDS.LINEA_MAINNET], destNetworkAllowlist: [CHAIN_IDS.LINEA_MAINNET], + destTokens: { + '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { + iconUrl: 'http://url', + symbol: 'UNI', + address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + decimals: 6, + }, + }, }, { - fromTokenInputValue: 1, + fromTokenInputValue: '1', fromToken: { address: '0x3103910', decimals: 6 }, toToken: { iconUrl: 'http://url', @@ -90,6 +102,7 @@ describe('PrepareBridgePage', () => { expect(getByTestId('from-amount')).toBeInTheDocument(); expect(getByTestId('from-amount').closest('input')).not.toBeDisabled(); expect(getByTestId('from-amount').closest('input')).toHaveValue(1); + await act(() => { fireEvent.change(getByTestId('from-amount'), { target: { value: '2' } }); }); diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index f71dc0813d33..46fbdd77786b 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -41,6 +41,7 @@ import { QuoteRequest } from '../types'; import { calcTokenValue } from '../../../../shared/lib/swaps-utils'; import { BridgeQuoteCard } from '../quotes/bridge-quote-card'; import { isValidQuoteRequest } from '../utils/quote'; +import { getProviderConfig } from '../../../ducks/metamask/metamask'; import { BridgeInputGroup } from './bridge-input-group'; const PrepareBridgePage = () => { @@ -64,6 +65,8 @@ const PrepareBridgePage = () => { const fromAmount = useSelector(getFromAmount); const toAmount = useSelector(getToAmount); + const providerConfig = useSelector(getProviderConfig); + const quoteRequest = useSelector(getQuoteRequest); const fromTokenListGenerator = useTokensWithFiltering( @@ -95,8 +98,19 @@ const PrepareBridgePage = () => { destChainId: toChain?.chainId ? Number(hexToDecimal(toChain.chainId)) : undefined, + // This override allows quotes to be returned when the rpcUrl is a tenderly fork + // Otherwise quotes get filtered out by the bridge-api when the wallet's real + // balance is less than the tenderly balance + insufficientBal: Boolean(providerConfig?.rpcUrl?.includes('tenderly')), }), - [fromToken, toToken, fromChain?.chainId, toChain?.chainId, fromAmount], + [ + fromToken, + toToken, + fromChain?.chainId, + toChain?.chainId, + fromAmount, + providerConfig, + ], ); const debouncedUpdateQuoteRequestInController = useCallback( diff --git a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx index b9bfc69e8e5a..274ade65a4d1 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.test.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.test.tsx @@ -23,9 +23,14 @@ describe('BridgeQuoteCard', () => { }, { fromTokenInputValue: 1 }, { + quoteRequest: { insufficientBal: false }, + quotesRefreshCount: 1, quotes: mockBridgeQuotesErc20Erc20, getQuotesLastFetched: Date.now(), quotesLoadingStatus: RequestStatus.FETCHED, + bridgeFeatureFlags: { + extensionConfig: { maxRefreshCount: 5, refreshRate: 30000 }, + }, }, ); const { container } = renderWithProvider( diff --git a/ui/pages/bridge/quotes/bridge-quote-card.tsx b/ui/pages/bridge/quotes/bridge-quote-card.tsx index 2928565809af..fc1176c8c3f9 100644 --- a/ui/pages/bridge/quotes/bridge-quote-card.tsx +++ b/ui/pages/bridge/quotes/bridge-quote-card.tsx @@ -20,7 +20,7 @@ import { BridgeQuotesModal } from './bridge-quotes-modal'; export const BridgeQuoteCard = () => { const t = useI18nContext(); const recommendedQuote = useSelector(getRecommendedQuote); - const { isLoading } = useSelector(getBridgeQuotes); + const { isLoading, isQuoteGoingToRefresh } = useSelector(getBridgeQuotes); const { etaInMinutes, totalFees, quoteRate } = getQuoteDisplayData(recommendedQuote); @@ -44,7 +44,7 @@ export const BridgeQuoteCard = () => { onClose={() => setShowAllQuotes(false)} /> - {!isLoading && ( + {!isLoading && isQuoteGoingToRefresh && ( {t('swapNewQuoteIn', [secondsUntilNextRefresh])} )} diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts index 5d001e7ef7fc..a1ee163eca48 100644 --- a/ui/pages/bridge/types.ts +++ b/ui/pages/bridge/types.ts @@ -6,6 +6,9 @@ export enum BridgeFlag { NETWORK_DEST_ALLOWLIST = 'dest-network-allowlist', } +type DecimalChainId = string; +export type GasMultiplierByChainId = Record; + export type FeatureFlagResponse = { [BridgeFlag.EXTENSION_CONFIG]: { refreshRate: number; @@ -89,7 +92,7 @@ export type QuoteResponse = { estimatedProcessingTimeInSeconds: number; }; -enum ChainId { +export enum ChainId { ETH = 1, OPTIMISM = 10, BSC = 56, diff --git a/ui/pages/confirmations/components/confirm/footer/footer.tsx b/ui/pages/confirmations/components/confirm/footer/footer.tsx index a37812899ec9..a9aea54c03f7 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.tsx @@ -34,13 +34,13 @@ import { selectUseTransactionSimulations } from '../../../selectors/preferences' import { isPermitSignatureRequest, isSIWESignatureRequest, - REDESIGN_DEV_TRANSACTION_TYPES, } from '../../../utils'; import { useConfirmContext } from '../../../context/confirm'; import { getConfirmationSender } from '../utils'; import { MetaMetricsEventLocation } from '../../../../../../shared/constants/metametrics'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; export type OnCancelHandler = ({ location, @@ -218,9 +218,10 @@ const Footer = () => { return; } - const isTransactionConfirmation = REDESIGN_DEV_TRANSACTION_TYPES.find( - (type) => type === currentConfirmation?.type, + const isTransactionConfirmation = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); + if (isTransactionConfirmation) { const mergeTxDataWithNonce = (transactionData: TransactionMeta) => customNonceValue diff --git a/ui/pages/confirmations/components/confirm/header/header-info.tsx b/ui/pages/confirmations/components/confirm/header/header-info.tsx index 9cc50b0fe676..03eabacef42e 100644 --- a/ui/pages/confirmations/components/confirm/header/header-info.tsx +++ b/ui/pages/confirmations/components/confirm/header/header-info.tsx @@ -1,4 +1,3 @@ -import { TransactionType } from '@metamask/transaction-controller'; import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; import { @@ -42,10 +41,8 @@ import { useConfirmContext } from '../../../context/confirm'; import { useBalance } from '../../../hooks/useBalance'; import useConfirmationRecipientInfo from '../../../hooks/useConfirmationRecipientInfo'; import { SignatureRequestType } from '../../../types/confirm'; -import { - isSignatureTransactionType, - REDESIGN_DEV_TRANSACTION_TYPES, -} from '../../../utils/confirm'; +import { isSignatureTransactionType } from '../../../utils/confirm'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; import { AdvancedDetailsButton } from './advanced-details-button'; const HeaderInfo = () => { @@ -89,8 +86,8 @@ const HeaderInfo = () => { trackEvent(event); } - const isShowAdvancedDetailsToggle = REDESIGN_DEV_TRANSACTION_TYPES.includes( - currentConfirmation?.type as TransactionType, + const isShowAdvancedDetailsToggle = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); return ( diff --git a/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap index 38219749e987..f0c3a5183789 100644 --- a/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/__snapshots__/info.test.tsx.snap @@ -102,7 +102,7 @@ exports[`Info renders info section for approve request 1`] = ` class="mm-box mm-text mm-text--body-md mm-box--margin-right-2 mm-box--color-text-alternative" data-testid="native-currency" > - $0.04 + $0.08

- -

- - - - - - - - - + #undefined +

- $0.04 + $0.08

+ > + #undefined +

`; diff --git a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx index 2f36d10ce42c..405236fe66da 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/nft-send-heading/nft-send-heading.tsx @@ -1,19 +1,23 @@ +import { Nft } from '@metamask/assets-controllers'; import { TransactionMeta } from '@metamask/transaction-controller'; import React from 'react'; -import { - AvatarToken, - AvatarTokenSize, - Box, - Text, -} from '../../../../../../../components/component-library'; +import { useSelector } from 'react-redux'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../../../../shared/constants/network'; +import { isEqualCaseInsensitive } from '../../../../../../../../shared/modules/string-utils'; +import { Box, Text } from '../../../../../../../components/component-library'; +import { NftItem } from '../../../../../../../components/multichain/nft-item'; +import { getNFTsByChainId } from '../../../../../../../ducks/metamask/metamask'; import { AlignItems, Display, FlexDirection, JustifyContent, + TextAlign, TextColor, TextVariant, } from '../../../../../../../helpers/constants/design-system'; +import { getNftImageAlt } from '../../../../../../../helpers/utils/nfts'; +import { getNetworkConfigurationsByChainId } from '../../../../../../../selectors'; import { useConfirmContext } from '../../../../../context/confirm'; import { useAssetDetails } from '../../../../../hooks/useAssetDetails'; @@ -25,21 +29,53 @@ const NFTSendHeading = () => { const userAddress = transactionMeta.txParams.from; const { data } = transactionMeta.txParams; const { chainId } = transactionMeta; + const { + assetName, + tokenImage, + tokenId: assetTokenId, + } = useAssetDetails(tokenAddress, userAddress, data, chainId); + const nfts: Nft[] = useSelector((state) => + getNFTsByChainId(state, chainId), + ) as Nft[]; + const networkConfigurations = useSelector(getNetworkConfigurationsByChainId); + const nft: Nft | undefined = + assetTokenId && + nfts.find( + ({ address, tokenId }: Nft) => + isEqualCaseInsensitive(address, tokenAddress as string) && + assetTokenId === tokenId.toString(), + ); + const imageOriginal = (nft as Nft | undefined)?.imageOriginal; + const image = (nft as Nft | undefined)?.image; + const nftImageAlt = nft && getNftImageAlt(nft); + const nftSrcUrl = imageOriginal ?? (image || ''); + const isIpfsURL = nftSrcUrl?.startsWith('ipfs:'); + const currentChain = networkConfigurations[chainId]; - const { assetName, tokenImage, tokenId } = useAssetDetails( - tokenAddress, - userAddress, - data, - chainId, + const TokenImage = ( + + + ); - const TokenImage = ; - const TokenName = ( {assetName} @@ -47,7 +83,7 @@ const NFTSendHeading = () => { const TokenID = ( - {tokenId} + {`#${assetTokenId}`} ); diff --git a/ui/pages/confirmations/components/confirm/info/shared/send-heading/__snapshots__/send-heading.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/send-heading/__snapshots__/send-heading.test.tsx.snap index 677a5a357155..fdc069a399dc 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/send-heading/__snapshots__/send-heading.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/send-heading/__snapshots__/send-heading.test.tsx.snap @@ -3,7 +3,7 @@ exports[` renders component 1`] = `
{ const t = useI18nContext(); const { currentConfirmation: transactionMeta } = useConfirmContext(); - const locale = useSelector(getIntlLocale); const { tokenImage, tokenSymbol } = useTokenDetails(transactionMeta); const { decodedTransferValue, @@ -66,21 +62,20 @@ const SendHeading = () => { ); const TokenValue = - displayTransferValue === - `<${formatAmountMaxPrecision(locale, MIN_AMOUNT)}` ? ( - + displayTransferValue === decodedTransferValue ? ( + {`${displayTransferValue} ${tokenSymbol}`} + ) : ( + {`${displayTransferValue} ${tokenSymbol}`} - ) : ( - {`${displayTransferValue} ${tokenSymbol}`} ); const TokenFiatValue = Boolean(fiatDisplayValue) && diff --git a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx index 7901854b91d1..9a34abd8009d 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/shared/static-simulation/static-simulation.tsx @@ -1,22 +1,36 @@ import React from 'react'; + +import { Box } from '../../../../../../../components/component-library'; import { ConfirmInfoRow, ConfirmInfoRowText, } from '../../../../../../../components/app/confirm/info/row'; import { ConfirmInfoSection } from '../../../../../../../components/app/confirm/info/row/section'; +import { + Display, + JustifyContent, +} from '../../../../../../../helpers/constants/design-system'; +import Preloader from '../../../../../../../components/ui/icon/preloader'; const StaticSimulation: React.FC<{ title: string; titleTooltip: string; - description: string; + description?: string; simulationElements: React.ReactNode; -}> = ({ title, titleTooltip, description, simulationElements }) => { + isLoading?: boolean; +}> = ({ title, titleTooltip, description, simulationElements, isLoading }) => { return ( - + {description && } - {simulationElements} + {isLoading ? ( + + + + ) : ( + simulationElements + )} ); }; diff --git a/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap index 1b7fd2aeb460..cc66da7a1375 100644 --- a/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap +++ b/ui/pages/confirmations/components/confirm/info/shared/transaction-data/__snapshots__/transaction-data.test.tsx.snap @@ -57,6 +57,15 @@ exports[`TransactionData renders decoded data with names and descriptions 1`] =
+
-
+
-
+
-
+
-
- - - - - - - - - -
-
- $0.04 + $0.08

+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+ + + + + + + + + +
+
+
+`; + +exports[`PermitSimulation should render default simulation if decoding api does not return result 1`] = `
`; -exports[`PermitSimulation renders correctly for NFT permit 1`] = ` +exports[`PermitSimulation should render default simulation if decoding api returns error 1`] = `
- You're giving someone else permission to withdraw NFTs from your account. + You're giving the spender permission to spend this many tokens from your account.

@@ -190,7 +276,7 @@ exports[`PermitSimulation renders correctly for NFT permit 1`] = `

- Withdraw + Spending cap

@@ -207,14 +293,25 @@ exports[`PermitSimulation renders correctly for NFT permit 1`] = `
-
-

+

- #3606393 -

+

+ 30 +

+
- 0xC3644...1FE88 + 0xCcCCc...ccccC

diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap new file mode 100644 index 000000000000..9a794df953fc --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/__snapshots__/decoded-simulation.test.tsx.snap @@ -0,0 +1,105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DecodedSimulation renders component correctly 1`] = ` +
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+

+ Spending cap +

+
+
+
+
+
+

+ 1,461,501,637,3... +

+
+
+
+
+
+ +

+ 0x6B175...71d0F +

+
+
+
+
+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx new file mode 100644 index 000000000000..690cfb5b5195 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.test.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { + DecodingData, + DecodingDataChangeType, +} from '@metamask/signature-controller'; + +import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../../test/data/confirmations/helper'; +import { renderWithConfirmContextProvider } from '../../../../../../../../../test/lib/confirmations/render-helpers'; +import { permitSignatureMsg } from '../../../../../../../../../test/data/confirmations/typed_sign'; +import PermitSimulation from './decoded-simulation'; + +const decodingData: DecodingData = { + stateChanges: [ + { + assetType: 'ERC20', + changeType: DecodingDataChangeType.Approve, + address: '0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad', + amount: '1461501637330902918203684832716283019655932542975', + contractAddress: '0x6b175474e89094c44da98b954eedeac495271d0f', + }, + ], +}; + +describe('DecodedSimulation', () => { + it('renders component correctly', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData, + }); + const mockStore = configureMockStore([])(state); + + const { container } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx new file mode 100644 index 000000000000..b0a1ddee12e6 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/decoded-simulation.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + DecodingDataChangeType, + DecodingDataStateChange, +} from '@metamask/signature-controller'; +import { Hex } from '@metamask/utils'; + +import { TokenStandard } from '../../../../../../../../../shared/constants/transaction'; +import { + Box, + Text, +} from '../../../../../../../../components/component-library'; +import { + TextColor, + TextVariant, +} from '../../../../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../../../../hooks/useI18nContext'; +import { SignatureRequestType } from '../../../../../../types/confirm'; +import { useConfirmContext } from '../../../../../../context/confirm'; +import StaticSimulation from '../../../shared/static-simulation/static-simulation'; +import PermitSimulationValueDisplay from '../value-display/value-display'; + +const getStateChangeLabelMap = ( + t: ReturnType, + changeType: string, +) => + ({ + [DecodingDataChangeType.Transfer]: t('permitSimulationChange_transfer'), + [DecodingDataChangeType.Receive]: t('permitSimulationChange_receive'), + [DecodingDataChangeType.Approve]: t('permitSimulationChange_approve'), + [DecodingDataChangeType.Revoke]: t('permitSimulationChange_revoke'), + [DecodingDataChangeType.Bidding]: t('permitSimulationChange_bidding'), + [DecodingDataChangeType.Listing]: t('permitSimulationChange_listing'), + }[changeType]); + +const StateChangeRow = ({ + stateChange, + chainId, +}: { + stateChange: DecodingDataStateChange; + chainId: Hex; +}) => { + const t = useI18nContext(); + const { assetType, changeType, amount, contractAddress, tokenID } = + stateChange; + return ( + + + {getStateChangeLabelMap(t, changeType)} + + {(assetType === TokenStandard.ERC20 || + assetType === TokenStandard.ERC721) && ( + + )} + + ); +}; + +const DecodedSimulation: React.FC = () => { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + const chainId = currentConfirmation.chainId as Hex; + const { decodingLoading, decodingData } = currentConfirmation; + + const stateChangeFragment = (decodingData?.stateChanges ?? []).map( + (change: DecodingDataStateChange) => ( + + ), + ); + + return ( + + ); +}; + +export default DecodedSimulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/index.ts b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/index.ts new file mode 100644 index 000000000000..a13982b1b09d --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/decoded-simulation/index.ts @@ -0,0 +1 @@ +export { default as DecodedSimulation } from './decoded-simulation'; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap new file mode 100644 index 000000000000..a378d0befcc4 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/__snapshots__/default-simulation.test.tsx.snap @@ -0,0 +1,246 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultSimulation renders component correctly 1`] = ` +
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+

+ You're giving the spender permission to spend this many tokens from your account. +

+
+
+
+
+
+

+ Spending cap +

+
+
+
+
+
+
+
+
+

+ 30 +

+
+
+
+
+
+ +

+ 0xCcCCc...ccccC +

+
+
+
+
+
+
+
+
+
+`; + +exports[`DefaultSimulation renders correctly for NFT permit 1`] = ` +
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+

+ You're giving someone else permission to withdraw NFTs from your account. +

+
+
+
+
+
+

+ Withdraw +

+
+
+
+
+
+
+
+

+ #3606393 +

+
+
+
+
+ +

+ 0xC3644...1FE88 +

+
+
+
+
+
+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.test.tsx new file mode 100644 index 000000000000..b9e095292bce --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { act } from 'react-dom/test-utils'; + +import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../../test/data/confirmations/helper'; +import { renderWithConfirmContextProvider } from '../../../../../../../../../test/lib/confirmations/render-helpers'; +import { + permitNFTSignatureMsg, + permitSignatureMsg, +} from '../../../../../../../../../test/data/confirmations/typed_sign'; +import { memoizedGetTokenStandardAndDetails } from '../../../../../../utils/token'; +import DefaultSimulation from './default-simulation'; + +jest.mock('../../../../../../../../store/actions', () => { + return { + getTokenStandardAndDetails: jest + .fn() + .mockResolvedValue({ decimals: 2, standard: 'ERC20' }), + }; +}); + +describe('DefaultSimulation', () => { + afterEach(() => { + jest.clearAllMocks(); + + /** Reset memoized function using getTokenStandardAndDetails for each test */ + memoizedGetTokenStandardAndDetails?.cache?.clear?.(); + }); + + it('renders component correctly', async () => { + const state = getMockTypedSignConfirmStateForRequest(permitSignatureMsg); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { container, findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('30')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + }); + + it('renders correctly for NFT permit', async () => { + const state = getMockTypedSignConfirmStateForRequest(permitNFTSignatureMsg); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { container, findByText } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(await findByText('Withdraw')).toBeInTheDocument(); + expect(await findByText('#3606393')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx new file mode 100644 index 000000000000..c39a88585e35 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/default-simulation.tsx @@ -0,0 +1,118 @@ +import { Hex } from '@metamask/utils'; +import React from 'react'; +import { PrimaryType } from '../../../../../../../../../shared/constants/signatures'; +import { parseTypedDataMessage } from '../../../../../../../../../shared/modules/transaction.utils'; +import { ConfirmInfoRow } from '../../../../../../../../components/app/confirm/info/row'; +import { Box } from '../../../../../../../../components/component-library'; +import { + Display, + FlexDirection, +} from '../../../../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../../../../hooks/useI18nContext'; +import { useConfirmContext } from '../../../../../../context/confirm'; +import { SignatureRequestType } from '../../../../../../types/confirm'; +import StaticSimulation from '../../../shared/static-simulation/static-simulation'; +import PermitSimulationValueDisplay from '../value-display/value-display'; + +function extractTokenDetailsByPrimaryType( + message: Record, + primaryType: PrimaryType, +): object[] | unknown { + let tokenDetails; + + switch (primaryType) { + case PrimaryType.PermitBatch: + case PrimaryType.PermitSingle: + tokenDetails = message?.details; + break; + case PrimaryType.PermitBatchTransferFrom: + case PrimaryType.PermitTransferFrom: + tokenDetails = message?.permitted; + break; + default: + break; + } + + const isNonArrayObject = tokenDetails && !Array.isArray(tokenDetails); + + return isNonArrayObject ? [tokenDetails] : tokenDetails; +} + +const DefaultPermitSimulation: React.FC = () => { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + const msgData = currentConfirmation.msgParams?.data; + const chainId = currentConfirmation.chainId as Hex; + const { + domain: { verifyingContract }, + message, + message: { tokenId }, + primaryType, + } = parseTypedDataMessage(msgData as string); + const isNFT = tokenId !== undefined; + + const tokenDetails = extractTokenDetailsByPrimaryType(message, primaryType); + + const TokenDetail = ({ + token, + amount, + i, + }: { + token: Hex | string; + amount: number | string; + i: number; + }) => ( + + ); + + const SpendingCapRow = ( + + + {Array.isArray(tokenDetails) ? ( + + {tokenDetails.map( + ( + { token, amount }: { token: string; amount: string }, + i: number, + ) => ( + + ), + )} + + ) : ( + + )} + + + ); + + return ( + + ); +}; + +export default DefaultPermitSimulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts new file mode 100644 index 000000000000..b9f6c48b65db --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/default-simulation/index.ts @@ -0,0 +1 @@ +export { default as DefaultSimulation } from './default-simulation'; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx index 0d67715867d9..d0183d1ac2e7 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.test.tsx @@ -1,13 +1,11 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { act } from 'react-dom/test-utils'; +import { waitFor } from '@testing-library/dom'; import { getMockTypedSignConfirmStateForRequest } from '../../../../../../../../test/data/confirmations/helper'; import { renderWithConfirmContextProvider } from '../../../../../../../../test/lib/confirmations/render-helpers'; -import { - permitNFTSignatureMsg, - permitSignatureMsg, -} from '../../../../../../../../test/data/confirmations/typed_sign'; +import { permitSignatureMsg } from '../../../../../../../../test/data/confirmations/typed_sign'; import { memoizedGetTokenStandardAndDetails } from '../../../../../utils/token'; import PermitSimulation from './permit-simulation'; @@ -27,8 +25,12 @@ describe('PermitSimulation', () => { memoizedGetTokenStandardAndDetails?.cache?.clear?.(); }); - it('renders component correctly', async () => { - const state = getMockTypedSignConfirmStateForRequest(permitSignatureMsg); + it('should render default simulation if decoding api does not return result', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: undefined, + }); const mockStore = configureMockStore([])(state); await act(async () => { @@ -42,8 +44,18 @@ describe('PermitSimulation', () => { }); }); - it('renders correctly for NFT permit', async () => { - const state = getMockTypedSignConfirmStateForRequest(permitNFTSignatureMsg); + it('should render default simulation if decoding api returns error', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: false, + decodingData: { + stateChanges: null, + error: { + message: 'some error', + type: 'SOME_ERROR', + }, + }, + }); const mockStore = configureMockStore([])(state); await act(async () => { @@ -52,9 +64,28 @@ describe('PermitSimulation', () => { mockStore, ); - expect(await findByText('Withdraw')).toBeInTheDocument(); - expect(await findByText('#3606393')).toBeInTheDocument(); + expect(await findByText('30')).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); }); + + it('should not render default simulation if decodingLoading is true', async () => { + const state = getMockTypedSignConfirmStateForRequest({ + ...permitSignatureMsg, + decodingLoading: true, + }); + const mockStore = configureMockStore([])(state); + + await act(async () => { + const { container, queryByTestId } = renderWithConfirmContextProvider( + , + mockStore, + ); + + await waitFor(() => { + expect(queryByTestId('30')).not.toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + }); + }); }); diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx index 855f7837ba42..0b4d9eed22d4 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/permit-simulation.tsx @@ -1,118 +1,22 @@ -import { Hex } from '@metamask/utils'; import React from 'react'; -import { PrimaryType } from '../../../../../../../../shared/constants/signatures'; -import { parseTypedDataMessage } from '../../../../../../../../shared/modules/transaction.utils'; -import { ConfirmInfoRow } from '../../../../../../../components/app/confirm/info/row'; -import { Box } from '../../../../../../../components/component-library'; -import { - Display, - FlexDirection, -} from '../../../../../../../helpers/constants/design-system'; -import { useI18nContext } from '../../../../../../../hooks/useI18nContext'; -import { useConfirmContext } from '../../../../../context/confirm'; -import { SignatureRequestType } from '../../../../../types/confirm'; -import StaticSimulation from '../../shared/static-simulation/static-simulation'; -import PermitSimulationValueDisplay from './value-display/value-display'; - -function extractTokenDetailsByPrimaryType( - message: Record, - primaryType: PrimaryType, -): object[] | unknown { - let tokenDetails; - - switch (primaryType) { - case PrimaryType.PermitBatch: - case PrimaryType.PermitSingle: - tokenDetails = message?.details; - break; - case PrimaryType.PermitBatchTransferFrom: - case PrimaryType.PermitTransferFrom: - tokenDetails = message?.permitted; - break; - default: - break; - } - - const isNonArrayObject = tokenDetails && !Array.isArray(tokenDetails); - return isNonArrayObject ? [tokenDetails] : tokenDetails; -} +import { SignatureRequestType } from '../../../../../types/confirm'; +import { useConfirmContext } from '../../../../../context/confirm'; +import { DefaultSimulation } from './default-simulation'; +import { DecodedSimulation } from './decoded-simulation'; const PermitSimulation: React.FC = () => { - const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); - const msgData = currentConfirmation.msgParams?.data; - const chainId = currentConfirmation.chainId as Hex; - const { - domain: { verifyingContract }, - message, - message: { tokenId }, - primaryType, - } = parseTypedDataMessage(msgData as string); - const isNFT = tokenId !== undefined; - - const tokenDetails = extractTokenDetailsByPrimaryType(message, primaryType); + const { decodingLoading, decodingData } = currentConfirmation; - const TokenDetail = ({ - token, - amount, - i, - }: { - token: Hex | string; - amount: number | string; - i: number; - }) => ( - - ); - - const SpendingCapRow = ( - - - {Array.isArray(tokenDetails) ? ( - - {tokenDetails.map( - ( - { token, amount }: { token: string; amount: string }, - i: number, - ) => ( - - ), - )} - - ) : ( - - )} - - - ); + if ( + decodingData?.error || + (decodingData === undefined && decodingLoading !== true) + ) { + return ; + } - return ( - - ); + return ; }; export default PermitSimulation; diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx index c7a9eae6a496..19c1bbb33d7f 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/permit-simulation/value-display/value-display.tsx @@ -27,6 +27,7 @@ import { Display, JustifyContent, TextAlign, + TextColor, } from '../../../../../../../../helpers/constants/design-system'; import Name from '../../../../../../../../components/app/name/name'; import { TokenDetailsERC20 } from '../../../../../../utils/token'; @@ -50,11 +51,25 @@ type PermitSimulationValueDisplayParams = { /** The tokenId for NFT */ tokenId?: string; + + /** True if value is being credited to wallet */ + credit?: boolean; + + /** True if value is being debited to wallet */ + debit?: boolean; }; const PermitSimulationValueDisplay: React.FC< PermitSimulationValueDisplayParams -> = ({ chainId, primaryType, tokenContract, tokenId, value }) => { +> = ({ + chainId, + primaryType, + tokenContract, + tokenId, + value, + credit, + debit, +}) => { const exchangeRate = useTokenExchangeRate(tokenContract); const tokenDetails = useGetTokenStandardAndDetails(tokenContract); @@ -97,6 +112,17 @@ const PermitSimulationValueDisplay: React.FC< return null; } + let valueColor = TextColor.textDefault; + let valueBackgroundColor = BackgroundColor.backgroundAlternative; + + if (credit) { + valueColor = TextColor.successDefault; + valueBackgroundColor = BackgroundColor.successMuted; + } else if (debit) { + valueColor = TextColor.errorDefault; + valueBackgroundColor = BackgroundColor.errorMuted; + } + return ( @@ -113,8 +139,9 @@ const PermitSimulationValueDisplay: React.FC< > { const history = useHistory(); @@ -64,7 +65,7 @@ const Nav = () => { // "/confirm-transaction/" history.replace( `${CONFIRM_TRANSACTION_ROUTE}/${nextConfirmation.id}${ - isSignatureApprovalRequest(nextConfirmation) + isCorrectSignatureApprovalType(nextConfirmation.type as ApprovalType) ? SIGNATURE_REQUEST_PATH : '' }`, diff --git a/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx b/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx index c61053818923..61cb8f65563c 100644 --- a/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx +++ b/ui/pages/confirmations/components/confirm/scroll-to-bottom/scroll-to-bottom.tsx @@ -1,6 +1,5 @@ import React, { useContext, useEffect } from 'react'; import { useSelector } from 'react-redux'; -import { TransactionType } from '@metamask/transaction-controller'; import { Box, ButtonIcon, @@ -21,7 +20,7 @@ import { usePrevious } from '../../../../../hooks/usePrevious'; import { useScrollRequired } from '../../../../../hooks/useScrollRequired'; import { useConfirmContext } from '../../../context/confirm'; import { selectConfirmationAdvancedDetailsOpen } from '../../../selectors/preferences'; -import { REDESIGN_DEV_TRANSACTION_TYPES } from '../../../utils'; +import { isCorrectDeveloperTransactionType } from '../../../../../../shared/lib/confirmation.utils'; type ContentProps = { /** @@ -51,8 +50,8 @@ const ScrollToBottom = ({ children }: ContentProps) => { offsetPxFromBottom: 0, }); - const isTransactionRedesign = REDESIGN_DEV_TRANSACTION_TYPES.includes( - currentConfirmation?.type as TransactionType, + const isTransactionRedesign = isCorrectDeveloperTransactionType( + currentConfirmation?.type, ); const showScrollToBottom = diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx index 9294f7d1b5b5..e312de542772 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.test.tsx @@ -41,12 +41,16 @@ jest.mock('../../context/confirm', () => ({ })), })); -const renderSimulationDetails = (simulationData?: Partial) => +const renderSimulationDetails = ( + simulationData?: Partial, + metricsOnly?: boolean, +) => renderWithProvider( , store, ); @@ -141,4 +145,9 @@ describe('SimulationDetails', () => { {}, ); }); + + it('does not render any UI elements when metricsOnly is true', () => { + const { container } = renderSimulationDetails({}, true); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx index 57ee1f2ff3fb..357f71230a0c 100644 --- a/ui/pages/confirmations/components/simulation-details/simulation-details.tsx +++ b/ui/pages/confirmations/components/simulation-details/simulation-details.tsx @@ -34,6 +34,7 @@ import { useSimulationMetrics } from './useSimulationMetrics'; export type SimulationDetailsProps = { enableMetrics?: boolean; isTransactionsRedesign?: boolean; + metricsOnly?: boolean; transaction: TransactionMeta; }; @@ -225,11 +226,13 @@ const SimulationDetailsLayout: React.FC<{ * @param props.enableMetrics - Whether to enable simulation metrics. * @param props.isTransactionsRedesign - Whether or not the component is being * used inside the transaction redesign flow. + * @param props.metricsOnly - Whether to only track metrics and not render the UI. */ export const SimulationDetails: React.FC = ({ transaction, enableMetrics = false, isTransactionsRedesign = false, + metricsOnly = false, }: SimulationDetailsProps) => { const t = useI18nContext(); const { chainId, id: transactionId, simulationData } = transaction; @@ -244,6 +247,10 @@ export const SimulationDetails: React.FC = ({ transactionId, }); + if (metricsOnly) { + return null; + } + if (loading) { return ( 0; diff --git a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts index 2f26fcefbee9..30ab2e71947e 100644 --- a/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/useBlockaidAlerts.ts @@ -15,10 +15,8 @@ import { import { Alert } from '../../../../ducks/confirm-alerts/confirm-alerts'; import ZENDESK_URLS from '../../../../helpers/constants/zendesk-url'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { - SIGNATURE_TRANSACTION_TYPES, - REDESIGN_DEV_TRANSACTION_TYPES, -} from '../../utils'; +import { SIGNATURE_TRANSACTION_TYPES } from '../../utils'; +import { isCorrectDeveloperTransactionType } from '../../../../../shared/lib/confirmation.utils'; import { SecurityAlertResponse, SignatureRequestType, @@ -30,11 +28,6 @@ import { normalizeProviderAlert } from './utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires const zlib = require('zlib'); -const SUPPORTED_TRANSACTION_TYPES = [ - ...SIGNATURE_TRANSACTION_TYPES, - ...REDESIGN_DEV_TRANSACTION_TYPES, -]; - const IGNORED_RESULT_TYPES = [ BlockaidResultType.Benign, BlockaidResultType.Loading, @@ -74,7 +67,8 @@ const useBlockaidAlerts = (): Alert[] => { signatureSecurityAlertResponse || transactionSecurityAlertResponse; const isTransactionTypeSupported = - SUPPORTED_TRANSACTION_TYPES.includes(transactionType); + isCorrectDeveloperTransactionType(transactionType) || + SIGNATURE_TRANSACTION_TYPES.includes(transactionType); const isResultTypeIgnored = IGNORED_RESULT_TYPES.includes( securityAlertResponse?.result_type as BlockaidResultType, diff --git a/ui/pages/confirmations/hooks/test-utils.js b/ui/pages/confirmations/hooks/test-utils.js index 5e327b0467c5..0cda95770107 100644 --- a/ui/pages/confirmations/hooks/test-utils.js +++ b/ui/pages/confirmations/hooks/test-utils.js @@ -11,6 +11,7 @@ import { getTokenExchangeRates, getPreferences, selectConversionRateByChainId, + selectNetworkConfigurationByChainId, } from '../../../selectors'; import { @@ -121,6 +122,9 @@ export const generateUseSelectorRouter = ) { return 'USD'; } + if (selector === selectNetworkConfigurationByChainId) { + return '2'; + } if ( selector === getMultichainShouldShowFiat || selector === getShouldShowFiat diff --git a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts index cf5e8a1383a2..1771f807de25 100644 --- a/ui/pages/confirmations/hooks/useCurrentConfirmation.ts +++ b/ui/pages/confirmations/hooks/useCurrentConfirmation.ts @@ -1,8 +1,5 @@ import { ApprovalType } from '@metamask/controller-utils'; -import { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -17,10 +14,9 @@ import { } from '../../../selectors'; import { selectUnapprovedMessage } from '../../../selectors/signatures'; import { - REDESIGN_APPROVAL_TYPES, - REDESIGN_DEV_TRANSACTION_TYPES, - REDESIGN_USER_TRANSACTION_TYPES, -} from '../utils'; + shouldUseRedesignForSignatures, + shouldUseRedesignForTransactions, +} from '../../../../shared/lib/confirmation.utils'; /** * Determine the current confirmation based on the pending approvals and controller state. @@ -47,10 +43,6 @@ const useCurrentConfirmation = () => { getIsRedesignedConfirmationsDeveloperEnabled, ); - const isRedesignedConfirmationsDeveloperSettingEnabled = - process.env.ENABLE_CONFIRMATION_REDESIGN === 'true' || - isRedesignedConfirmationsDeveloperEnabled; - const pendingApproval = useSelector((state) => selectPendingApproval(state as ApprovalsMetaMaskState, confirmationId), ); @@ -64,37 +56,20 @@ const useCurrentConfirmation = () => { selectUnapprovedMessage(state, confirmationId), ); - const isCorrectUserTransactionType = REDESIGN_USER_TRANSACTION_TYPES.includes( - transactionMetadata?.type as TransactionType, - ); - - const isCorrectDeveloperTransactionType = - REDESIGN_DEV_TRANSACTION_TYPES.includes( - transactionMetadata?.type as TransactionType, - ); - - const isCorrectApprovalType = REDESIGN_APPROVAL_TYPES.includes( - pendingApproval?.type as ApprovalType, - ); - - const shouldUseRedesignForSignatures = - (isRedesignedSignaturesUserSettingEnabled && isCorrectApprovalType) || - (isRedesignedConfirmationsDeveloperSettingEnabled && isCorrectApprovalType); + const useRedesignedForSignatures = shouldUseRedesignForSignatures({ + approvalType: pendingApproval?.type as ApprovalType, + isRedesignedSignaturesUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, + }); - const shouldUseRedesignForTransactions = - (isRedesignedTransactionsUserSettingEnabled && - isCorrectUserTransactionType) || - (isRedesignedConfirmationsDeveloperSettingEnabled && - isCorrectDeveloperTransactionType); + const useRedesignedForTransaction = shouldUseRedesignForTransactions({ + transactionMetadataType: transactionMetadata?.type, + isRedesignedTransactionsUserSettingEnabled, + isRedesignedConfirmationsDeveloperEnabled, + }); - // If the developer toggle or the build time environment variable are enabled, - // all the signatures and transactions in development are shown. If the user - // facing feature toggles for signature or transactions are enabled, we show - // only confirmations that shipped (contained in `REDESIGN_APPROVAL_TYPES` and - // `REDESIGN_USER_TRANSACTION_TYPES` or `REDESIGN_DEV_TRANSACTION_TYPES` - // respectively). const shouldUseRedesign = - shouldUseRedesignForSignatures || shouldUseRedesignForTransactions; + useRedesignedForSignatures || useRedesignedForTransaction; return useMemo(() => { if (!shouldUseRedesign) { diff --git a/ui/pages/confirmations/hooks/useGasFeeInputs.js b/ui/pages/confirmations/hooks/useGasFeeInputs.js index 27a60ad5f043..8675b726038a 100644 --- a/ui/pages/confirmations/hooks/useGasFeeInputs.js +++ b/ui/pages/confirmations/hooks/useGasFeeInputs.js @@ -11,6 +11,7 @@ import { GAS_FORM_ERRORS } from '../../../helpers/constants/gas'; import { checkNetworkAndAccountSupports1559, getAdvancedInlineGasShown, + selectNetworkConfigurationByChainId, } from '../../../selectors'; import { isLegacyTransaction } from '../../../helpers/utils/transactions.util'; import { useGasFeeEstimates } from '../../../hooks/useGasFeeEstimates'; @@ -118,6 +119,13 @@ export function useGasFeeInputs( ? retryTxMeta : _transaction; + const network = useSelector((state) => + selectNetworkConfigurationByChainId(state, transaction?.chainId), + ); + + const networkClientId = + network?.rpcEndpoints?.[network?.defaultRpcEndpointIndex]?.networkClientId; + const supportsEIP1559 = useSelector(checkNetworkAndAccountSupports1559) && !isLegacyTransaction(transaction?.txParams); @@ -130,7 +138,7 @@ export function useGasFeeInputs( gasFeeEstimates, isGasEstimatesLoading, isNetworkBusy, - } = useGasFeeEstimates(transaction?.networkClientId); + } = useGasFeeEstimates(networkClientId); const userPrefersAdvancedGas = useSelector(getAdvancedInlineGasShown); diff --git a/ui/pages/confirmations/hooks/useGasFeeInputs.test.js b/ui/pages/confirmations/hooks/useGasFeeInputs.test.js index c7477d548735..48f78f42977f 100644 --- a/ui/pages/confirmations/hooks/useGasFeeInputs.test.js +++ b/ui/pages/confirmations/hooks/useGasFeeInputs.test.js @@ -50,6 +50,7 @@ jest.mock('../../../hooks/useMultichainSelector', () => ({ const mockTransaction = { status: TransactionStatus.unapproved, type: TransactionType.simpleSend, + networkClientId: '2', txParams: { from: '0x000000000000000000000000000000000000dead', type: '0x2', @@ -94,6 +95,7 @@ describe('useGasFeeInputs', () => { checkNetworkAndAccountSupports1559Response: false, }), ); + const { result } = renderHook(() => useGasFeeInputs()); expect(result.current.gasPrice).toBe( LEGACY_GAS_ESTIMATE_RETURN_VALUE.gasFeeEstimates.medium, @@ -187,9 +189,19 @@ describe('useGasFeeInputs', () => { ); expect(result.current.balanceError).toBe(true); }); + + it('should call useGasFeeEstimates with correct networkClientId', () => { + renderHook(() => useGasFeeInputs(null, mockTransaction)); + expect(useGasFeeEstimates).not.toHaveBeenCalledWith('2'); + }); }); describe('editGasMode', () => { + beforeEach(() => { + useGasFeeEstimates.mockImplementation( + () => HIGH_FEE_MARKET_ESTIMATE_RETURN_VALUE, + ); + }); it('should return editGasMode passed', () => { const { result } = renderHook(() => useGasFeeInputs(undefined, undefined, undefined, EditGasModes.swaps), diff --git a/ui/pages/confirmations/send/send.scss b/ui/pages/confirmations/send/send.scss index c3b6a841c99e..0c2ab495d9de 100644 --- a/ui/pages/confirmations/send/send.scss +++ b/ui/pages/confirmations/send/send.scss @@ -57,6 +57,10 @@ height: 0; &__list { + &__duplicate-contact-banner { + padding: 8px 16px 0 16px; + } + &__link { @include design-system.Paragraph; diff --git a/ui/pages/confirmations/types/confirm.ts b/ui/pages/confirmations/types/confirm.ts index b7c0c1160b3c..fd344fcc2354 100644 --- a/ui/pages/confirmations/types/confirm.ts +++ b/ui/pages/confirmations/types/confirm.ts @@ -1,4 +1,5 @@ import { ApprovalControllerState } from '@metamask/approval-controller'; +import { DecodingData } from '@metamask/signature-controller'; import { SIWEMessage } from '@metamask/controller-utils'; import { TransactionMeta, @@ -38,6 +39,8 @@ export type SignatureRequestType = { type: TransactionType; custodyId?: string; securityAlertResponse?: SecurityAlertResponse; + decodingLoading?: boolean; + decodingData?: DecodingData; }; export type Confirmation = SignatureRequestType | TransactionMeta; diff --git a/ui/pages/confirmations/utils/confirm.test.ts b/ui/pages/confirmations/utils/confirm.test.ts index b1f6494ca627..9a8b3d1a0f8a 100644 --- a/ui/pages/confirmations/utils/confirm.test.ts +++ b/ui/pages/confirmations/utils/confirm.test.ts @@ -1,5 +1,3 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; -import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import { @@ -11,7 +9,6 @@ import { SignatureRequestType } from '../types/confirm'; import { isOrderSignatureRequest, isPermitSignatureRequest, - isSignatureApprovalRequest, isSignatureTransactionType, parseSanitizeTypedDataMessage, isValidASCIIURL, @@ -22,25 +19,6 @@ const typedDataMsg = '{"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF","0x06195827297c7A80a443b6894d3BDB8824b43896"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}'; describe('confirm util', () => { - describe('isSignatureApprovalRequest', () => { - it('returns true for signature approval requests', () => { - const result = isSignatureApprovalRequest({ - type: ApprovalType.PersonalSign, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as ApprovalRequest); - expect(result).toStrictEqual(true); - }); - it('returns false for request not of type signature', () => { - const result = isSignatureApprovalRequest({ - type: ApprovalType.Transaction, - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as ApprovalRequest); - expect(result).toStrictEqual(false); - }); - }); - describe('parseSanitizeTypedDataMessage', () => { it('parses and sanitizes data passed correctly', () => { const result = parseSanitizeTypedDataMessage(typedDataMsg); diff --git a/ui/pages/confirmations/utils/confirm.ts b/ui/pages/confirmations/utils/confirm.ts index a007ca0aa0b2..f464f51e8159 100644 --- a/ui/pages/confirmations/utils/confirm.ts +++ b/ui/pages/confirmations/utils/confirm.ts @@ -1,7 +1,4 @@ -import { ApprovalRequest } from '@metamask/approval-controller'; -import { ApprovalType } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; -import { Json } from '@metamask/utils'; import { PRIMARY_TYPES_ORDER, PRIMARY_TYPES_PERMIT, @@ -11,36 +8,6 @@ import { sanitizeMessage } from '../../../helpers/utils/util'; import { Confirmation, SignatureRequestType } from '../types/confirm'; import { TYPED_SIGNATURE_VERSIONS } from '../constants'; -export const REDESIGN_APPROVAL_TYPES = [ - ApprovalType.EthSignTypedData, - ApprovalType.PersonalSign, -]; - -export const REDESIGN_USER_TRANSACTION_TYPES = [ - TransactionType.contractInteraction, - TransactionType.deployContract, - TransactionType.tokenMethodApprove, - TransactionType.tokenMethodIncreaseAllowance, - TransactionType.tokenMethodSetApprovalForAll, - TransactionType.tokenMethodTransfer, - TransactionType.tokenMethodTransferFrom, - TransactionType.tokenMethodSafeTransferFrom, - TransactionType.simpleSend, -]; - -export const REDESIGN_DEV_TRANSACTION_TYPES = [ - ...REDESIGN_USER_TRANSACTION_TYPES, -]; - -const SIGNATURE_APPROVAL_TYPES = [ - ApprovalType.PersonalSign, - ApprovalType.EthSignTypedData, -]; - -export const isSignatureApprovalRequest = ( - request: ApprovalRequest>, -) => SIGNATURE_APPROVAL_TYPES.includes(request.type as ApprovalType); - export const SIGNATURE_TRANSACTION_TYPES = [ TransactionType.personalSign, TransactionType.signTypedData, diff --git a/ui/pages/onboarding-flow/pin-extension/pin-extension.js b/ui/pages/onboarding-flow/pin-extension/pin-extension.js index c9ad1806d49d..775095c2aa61 100644 --- a/ui/pages/onboarding-flow/pin-extension/pin-extension.js +++ b/ui/pages/onboarding-flow/pin-extension/pin-extension.js @@ -69,7 +69,9 @@ export default function OnboardingPinExtension() { if (selectedIndex === 0) { setSelectedIndex(1); } else { - dispatch(toggleExternalServices(externalServicesOnboardingToggleState)); + await dispatch( + toggleExternalServices(externalServicesOnboardingToggleState), + ); await dispatch(setCompletedOnboarding()); if (externalServicesOnboardingToggleState) { diff --git a/ui/pages/onboarding-flow/pin-extension/pin-extension.test.js b/ui/pages/onboarding-flow/pin-extension/pin-extension.test.js index 00c7c38cf1d0..22be22bd98ac 100644 --- a/ui/pages/onboarding-flow/pin-extension/pin-extension.test.js +++ b/ui/pages/onboarding-flow/pin-extension/pin-extension.test.js @@ -3,15 +3,31 @@ import { fireEvent } from '@testing-library/react'; import reactRouterDom from 'react-router-dom'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { setBackgroundConnection } from '../../../store/background-connection'; import { renderWithProvider } from '../../../../test/jest'; +import { + setCompletedOnboarding, + toggleExternalServices, +} from '../../../store/actions'; import PinExtension from './pin-extension'; -const completeOnboardingStub = jest - .fn() - .mockImplementation(() => Promise.resolve()); +jest.mock('../../../store/actions', () => ({ + toggleExternalServices: jest.fn(), + setCompletedOnboarding: jest.fn(), + performSignIn: jest.fn(), +})); + +const mockPromises = []; + +const mockDispatch = jest.fn().mockImplementation(() => { + const promise = Promise.resolve(); + mockPromises.push(promise); + return promise; +}); -const toggleExternalServicesStub = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -30,10 +46,6 @@ describe('Creation Successful Onboarding View', () => { }, }; const store = configureMockStore([thunk])(mockStore); - setBackgroundConnection({ - completeOnboarding: completeOnboardingStub, - toggleExternalServices: toggleExternalServicesStub, - }); const pushMock = jest.fn(); beforeAll(() => { @@ -43,12 +55,14 @@ describe('Creation Successful Onboarding View', () => { .mockReturnValue({ push: pushMock }); }); - it('should call completeOnboarding in the background when Done" button is clicked', () => { + it('should call completeOnboarding in the background when Done" button is clicked', async () => { const { getByText } = renderWithProvider(, store); const nextButton = getByText('Next'); fireEvent.click(nextButton); const gotItButton = getByText('Done'); fireEvent.click(gotItButton); - expect(completeOnboardingStub).toHaveBeenCalledTimes(1); + await Promise.all(mockPromises); + expect(toggleExternalServices).toHaveBeenCalledTimes(1); + expect(setCompletedOnboarding).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/pages/routes/routes.component.js b/ui/pages/routes/routes.component.js index 83e707c30f85..ef0ebbfa9ee3 100644 --- a/ui/pages/routes/routes.component.js +++ b/ui/pages/routes/routes.component.js @@ -114,6 +114,10 @@ import NetworkConfirmationPopover from '../../components/multichain/network-list import NftFullImage from '../../components/app/assets/nfts/nft-details/nft-full-image'; import CrossChainSwap from '../bridge'; import { ToastMaster } from '../../components/app/toast-master/toast-master'; +import { + isCorrectDeveloperTransactionType, + isCorrectSignatureApprovalType, +} from '../../../shared/lib/confirmation.utils'; import { getConnectingLabel, hideAppHeader, @@ -174,6 +178,9 @@ export default class Routes extends Component { currentExtensionPopupId: PropTypes.number, useRequestQueue: PropTypes.bool, clearEditedNetwork: PropTypes.func.isRequired, + oldestPendingApproval: PropTypes.object.isRequired, + pendingApprovals: PropTypes.arrayOf(PropTypes.object).isRequired, + transactionsMetadata: PropTypes.arrayOf(PropTypes.object).isRequired, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) isShowKeyringSnapRemovalResultModal: PropTypes.bool.isRequired, hideShowKeyringSnapRemovalResultModal: PropTypes.func.isRequired, @@ -419,6 +426,9 @@ export default class Routes extends Component { clearSwitchedNetworkDetails, clearEditedNetwork, privacyMode, + oldestPendingApproval, + pendingApprovals, + transactionsMetadata, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) isShowKeyringSnapRemovalResultModal, hideShowKeyringSnapRemovalResultModal, @@ -454,7 +464,27 @@ export default class Routes extends Component { isUnlocked && !shouldShowSeedPhraseReminder; - let isLoadingShown = isLoading && completedOnboarding; + const paramsConfirmationId = location.pathname.split( + '/confirm-transaction/', + )[1]; + const confirmationId = paramsConfirmationId ?? oldestPendingApproval?.id; + const pendingApproval = pendingApprovals.find( + (approval) => approval.id === confirmationId, + ); + const isCorrectApprovalType = isCorrectSignatureApprovalType( + pendingApproval?.type, + ); + const isCorrectTransactionType = isCorrectDeveloperTransactionType( + transactionsMetadata[confirmationId]?.type, + ); + + let isLoadingShown = + isLoading && + completedOnboarding && + // In the redesigned screens, we hide the general loading spinner and the + // loading states are on a component by component basis. + !isCorrectApprovalType && + !isCorrectTransactionType; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) isLoadingShown = @@ -464,7 +494,11 @@ export default class Routes extends Component { (confirmation) => confirmation.type === SNAP_MANAGE_ACCOUNTS_CONFIRMATION_TYPES.showSnapAccountRedirect, - ); + ) && + // In the redesigned screens, we hide the general loading spinner and the + // loading states are on a component by component basis. + !isCorrectApprovalType && + !isCorrectTransactionType; ///: END:ONLY_INCLUDE_IF return ( diff --git a/ui/pages/routes/routes.component.test.js b/ui/pages/routes/routes.component.test.js index 6151fedc687b..1b1823728a2f 100644 --- a/ui/pages/routes/routes.component.test.js +++ b/ui/pages/routes/routes.component.test.js @@ -15,6 +15,7 @@ import { useIsOriginalNativeTokenSymbol } from '../../hooks/useIsOriginalNativeT import { createMockInternalAccount } from '../../../test/jest/mocks'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { mockNetworkState } from '../../../test/stub/networks'; +import useMultiPolling from '../../hooks/useMultiPolling'; import Routes from '.'; const middlewares = [thunk]; @@ -43,8 +44,22 @@ jest.mock('../../store/actions', () => ({ .mockResolvedValue({ chainId: '0x5' }), showNetworkDropdown: () => mockShowNetworkDropdown, hideNetworkDropdown: () => mockHideNetworkDropdown, + tokenBalancesStartPolling: jest.fn().mockResolvedValue('pollingToken'), + tokenBalancesStopPollingByPollingToken: jest.fn(), + setTokenNetworkFilter: jest.fn(), })); +// Mock the dispatch function +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => { + const actual = jest.requireActual('react-redux'); + return { + ...actual, + useDispatch: () => mockDispatch, + }; +}); + jest.mock('../../ducks/bridge/actions', () => ({ setBridgeFeatureFlags: () => jest.fn(), })); @@ -77,6 +92,11 @@ jest.mock( '../../components/app/metamask-template-renderer/safe-component-list', ); +jest.mock('../../hooks/useMultiPolling', () => ({ + __esModule: true, + default: jest.fn(), +})); + const render = async (route, state) => { const store = configureMockStore(middlewares)({ ...mockSendState, @@ -95,6 +115,26 @@ const render = async (route, state) => { describe('Routes Component', () => { useIsOriginalNativeTokenSymbol.mockImplementation(() => true); + beforeEach(() => { + // Clear previous mock implementations + useMultiPolling.mockClear(); + + // Mock implementation for useMultiPolling + useMultiPolling.mockImplementation(({ input }) => { + // Mock startPolling and stopPollingByPollingToken for each input + const startPolling = jest.fn().mockResolvedValue('mockPollingToken'); + const stopPollingByPollingToken = jest.fn(); + + input.forEach((inputItem) => { + const key = JSON.stringify(inputItem); + // Simulate returning a unique token for each input + startPolling.mockResolvedValueOnce(`mockToken-${key}`); + }); + + return { startPolling, stopPollingByPollingToken }; + }); + }); + afterEach(() => { mockShowNetworkDropdown.mockClear(); mockHideNetworkDropdown.mockClear(); @@ -110,6 +150,7 @@ describe('Routes Component', () => { ...mockSendState.metamask.swapsState, swapsFeatureIsLive: true, }, + accountsByChainId: {}, pendingApprovals: {}, approvalFlows: [], announcements: {}, @@ -121,6 +162,10 @@ describe('Routes Component', () => { order: 'dsc', sortCallback: 'stringNumeric', }, + tokenNetworkFilter: {}, + }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', }, }, send: { @@ -156,12 +201,27 @@ describe('toast display', () => { ...mockState, metamask: { ...mockState.metamask, + allTokens: {}, announcements: {}, approvalFlows: [], completedOnboarding: true, usedNetworks: [], pendingApprovals: {}, pendingApprovalCount: 0, + preferences: { + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', + }, swapsState: { swapsFeatureIsLive: true }, newPrivacyPolicyToastShownDate: date, }, @@ -180,6 +240,17 @@ describe('toast display', () => { swapsState: { swapsFeatureIsLive: true }, newPrivacyPolicyToastShownDate: new Date(0), newPrivacyPolicyToastClickedOrClosed: true, + preferences: { + tokenSortConfig: { + key: 'token-sort-key', + order: 'dsc', + sortCallback: 'stringNumeric', + }, + tokenNetworkFilter: { + [CHAIN_IDS.MAINNET]: true, + [CHAIN_IDS.LINEA_MAINNET]: true, + }, + }, surveyLinkLastClickedOrClosed: true, showPrivacyPolicyToast: false, showSurveyToast: false, @@ -189,6 +260,9 @@ describe('toast display', () => { unconnectedAccount: true, }, termsOfUseLastAgreed: new Date(0).getTime(), + tokenBalances: { + '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': '0x176270e2b862e4ed3', + }, internalAccounts: { accounts: { [mockAccount.id]: mockAccount, diff --git a/ui/pages/routes/routes.container.js b/ui/pages/routes/routes.container.js index 5c08e939d1d1..e0fe5575e2c2 100644 --- a/ui/pages/routes/routes.container.js +++ b/ui/pages/routes/routes.container.js @@ -22,6 +22,9 @@ import { getNumberOfAllUnapprovedTransactionsAndMessages, getUseRequestQueue, getCurrentNetwork, + oldestPendingConfirmationSelector, + getUnapprovedTransactions, + getPendingApprovals, } from '../../selectors'; import { lockMetamask, @@ -69,6 +72,10 @@ function mapStateToProps(state) { getNetworkToAutomaticallySwitchTo(state); const switchedNetworkDetails = getSwitchedNetworkDetails(state); + const oldestPendingApproval = oldestPendingConfirmationSelector(state); + const pendingApprovals = getPendingApprovals(state); + const transactionsMetadata = getUnapprovedTransactions(state); + return { alertOpen, alertMessage, @@ -114,6 +121,9 @@ function mapStateToProps(state) { selectSwitchedNetworkNeverShowMessage(state), currentExtensionPopupId: state.metamask.currentExtensionPopupId, useRequestQueue: getUseRequestQueue(state), + oldestPendingApproval, + pendingApprovals, + transactionsMetadata, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) isShowKeyringSnapRemovalResultModal: state.appState.showKeyringRemovalSnapModal, diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js index 76f706bb8320..50aeec20ce3e 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.component.js @@ -12,6 +12,7 @@ import { } from '../../../../../shared/modules/hexstring-utils'; import { INVALID_RECIPIENT_ADDRESS_ERROR } from '../../../confirmations/send/send.constants'; import { DomainInputResolutionCell } from '../../../../components/multichain/pages/send/components'; +import { isDuplicateContact } from '../../../../components/app/contact-list/utils'; export default class AddContact extends PureComponent { static contextTypes = { @@ -19,6 +20,8 @@ export default class AddContact extends PureComponent { }; static propTypes = { + addressBook: PropTypes.array, + internalAccounts: PropTypes.array, addToAddressBook: PropTypes.func, history: PropTypes.object, scanQrCode: PropTypes.func, @@ -33,7 +36,8 @@ export default class AddContact extends PureComponent { state = { newName: '', selectedAddress: '', - error: '', + addressInputError: '', + nameInputError: '', input: '', }; @@ -69,9 +73,9 @@ export default class AddContact extends PureComponent { const validEnsAddress = isValidDomainName(input); if (!validEnsAddress && !valid) { - this.setState({ error: INVALID_RECIPIENT_ADDRESS_ERROR }); + this.setState({ addressInputError: INVALID_RECIPIENT_ADDRESS_ERROR }); } else { - this.setState({ error: null }); + this.setState({ addressInputError: null }); } }; @@ -100,12 +104,27 @@ export default class AddContact extends PureComponent { ); } + validateName = (newName) => { + const { addressBook, internalAccounts } = this.props; + return !isDuplicateContact(addressBook, internalAccounts, newName); + }; + + handleNameChange = (newName) => { + const isValidName = this.validateName(newName); + + this.setState({ + nameInputError: isValidName ? null : this.context.t('nameAlreadyInUse'), + }); + + this.setState({ newName }); + }; + render() { const { t } = this.context; const { history, addToAddressBook, domainError, domainResolutions } = this.props; - const errorToRender = domainError || this.state.error; + const addressError = domainError || this.state.addressInputError; const newAddress = this.state.selectedAddress || this.state.input; const validAddress = !isBurnAddress(newAddress) && @@ -121,10 +140,12 @@ export default class AddContact extends PureComponent { this.setState({ newName: e.target.value })} + onChange={(e) => this.handleNameChange(e.target.value)} fullWidth margin="dense" + error={this.state.nameInputError} /> @@ -152,9 +173,9 @@ export default class AddContact extends PureComponent { address={resolvedAddress} domainName={addressBookEntryName ?? domainName} onClick={() => { + this.handleNameChange(domainName); this.setState({ input: resolvedAddress, - newName: this.state.newName || domainName, }); this.props.resetDomainResolution(); }} @@ -164,9 +185,9 @@ export default class AddContact extends PureComponent { ); })} - {errorToRender && ( + {addressError && (
- {t(errorToRender)} + {t(addressError)}
)} @@ -174,7 +195,10 @@ export default class AddContact extends PureComponent { { await addToAddressBook(newAddress, this.state.newName); diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js index 3b18ebdde8e0..db7b87c48ea1 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.container.js @@ -12,10 +12,13 @@ import { getDomainResolutions, resetDomainResolution, } from '../../../../ducks/domains'; +import { getAddressBook, getInternalAccounts } from '../../../../selectors'; import AddContact from './add-contact.component'; const mapStateToProps = (state) => { return { + addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), qrCodeData: getQrCodeData(state), domainError: getDomainError(state), domainResolutions: getDomainResolutions(state), diff --git a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js index af130617ae19..e162f84ba40f 100644 --- a/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js +++ b/ui/pages/settings/contact-list-tab/add-contact/add-contact.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; @@ -7,6 +7,12 @@ import '@testing-library/jest-dom/extend-expect'; import { mockNetworkState } from '../../../../../test/stub/networks'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import { domainInitialState } from '../../../../ducks/domains'; +import { createMockInternalAccount } from '../../../../../test/jest/mocks'; +import { + MOCK_ADDRESS_BOOK, + MOCK_DOMAIN_RESOLUTION, +} from '../../../../../test/data/mock-data'; +import * as domainDucks from '../../../../ducks/domains'; import AddContact from './add-contact.component'; describe('AddContact component', () => { @@ -17,16 +23,29 @@ describe('AddContact component', () => { }, }; const props = { + addressBook: MOCK_ADDRESS_BOOK, + internalAccounts: [createMockInternalAccount()], history: { push: jest.fn() }, addToAddressBook: jest.fn(), scanQrCode: jest.fn(), qrCodeData: { type: 'address', values: { address: '0x123456789abcdef' } }, qrCodeDetected: jest.fn(), - domainResolution: '', + domainResolutions: [MOCK_DOMAIN_RESOLUTION], domainError: '', resetDomainResolution: jest.fn(), }; + beforeEach(() => { + jest.resetAllMocks(); + jest + .spyOn(domainDucks, 'lookupDomainName') + .mockImplementation(() => jest.fn()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should render the component with correct properties', () => { const store = configureMockStore(middleware)(state); @@ -36,7 +55,7 @@ describe('AddContact component', () => { expect(getByText('Ethereum public address')).toBeInTheDocument(); }); - it('should validate the address correctly', () => { + it('should validate the address correctly', async () => { const store = configureMockStore(middleware)(state); const { getByText, getByTestId } = renderWithProvider( , @@ -45,9 +64,10 @@ describe('AddContact component', () => { const input = getByTestId('ens-input'); fireEvent.change(input, { target: { value: 'invalid address' } }); - setTimeout(() => { - expect(getByText('Recipient address is invalid')).toBeInTheDocument(); - }, 600); + + await waitFor(() => + expect(getByText('Recipient address is invalid')).toBeInTheDocument(), + ); }); it('should get disabled submit button when username field is empty', () => { @@ -113,4 +133,105 @@ describe('AddContact component', () => { }); expect(getByText('Save')).toBeDisabled(); }); + + it('should disable the submit button when the name is an existing account name', () => { + const duplicateName = 'Account 1'; + + const store = configureMockStore(middleware)(state); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + + const nameInput = document.getElementById('nickname'); + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const addressInput = getByTestId('ens-input'); + + fireEvent.change(addressInput, { + target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' }, + }); + + const saveButton = getByText('Save'); + expect(saveButton).toBeDisabled(); + }); + + it('should disable the submit button when the name is an existing contact name', () => { + const duplicateName = MOCK_ADDRESS_BOOK[0].name; + + const store = configureMockStore(middleware)(state); + const { getByText, getByTestId } = renderWithProvider( + , + store, + ); + + const nameInput = document.getElementById('nickname'); + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const addressInput = getByTestId('ens-input'); + + fireEvent.change(addressInput, { + target: { value: '0x43c9159B6251f3E205B9113A023C8256cDD40D91' }, + }); + + const saveButton = getByText('Save'); + expect(saveButton).toBeDisabled(); + }); + + it('should display error message when name entered is an existing account name', () => { + const duplicateName = 'Account 1'; + + const store = configureMockStore(middleware)(state); + + const { getByText } = renderWithProvider(, store); + + const nameInput = document.getElementById('nickname'); + + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); + + it('should display error message when name entered is an existing contact name', () => { + const duplicateName = MOCK_ADDRESS_BOOK[0].name; + + const store = configureMockStore(middleware)(state); + + const { getByText } = renderWithProvider(, store); + + const nameInput = document.getElementById('nickname'); + + fireEvent.change(nameInput, { target: { value: duplicateName } }); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); + + it('should display error when ENS inserts a name that is already in use', () => { + const store = configureMockStore(middleware)(state); + + const { getByTestId, getByText } = renderWithProvider( + , + store, + ); + + const ensInput = getByTestId('ens-input'); + fireEvent.change(ensInput, { target: { value: 'example.eth' } }); + + const domainResolutionCell = getByTestId( + 'multichain-send-page__recipient__item', + ); + + fireEvent.click(domainResolutionCell); + + const saveButton = getByText('Save'); + + expect(getByText('Name is already in use')).toBeDefined(); + expect(saveButton).toBeDisabled(); + }); }); diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js index 6da9bbf4d14f..83cbaccd99d8 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.component.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.component.js @@ -29,6 +29,7 @@ export default class ContactListTab extends Component { static propTypes = { addressBook: PropTypes.array, + internalAccounts: PropTypes.array, history: PropTypes.object, selectedAddress: PropTypes.string, viewingContact: PropTypes.bool, @@ -57,7 +58,8 @@ export default class ContactListTab extends Component { } renderAddresses() { - const { addressBook, history, selectedAddress } = this.props; + const { addressBook, internalAccounts, history, selectedAddress } = + this.props; const contacts = addressBook.filter(({ name }) => Boolean(name)); const nonContacts = addressBook.filter(({ name }) => !name); const { t } = this.context; @@ -66,6 +68,8 @@ export default class ContactListTab extends Component { return (
contacts} searchForRecents={() => nonContacts} selectRecipient={(address) => { diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js index 98991e7e744d..b1715715b4f7 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.container.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.container.js @@ -1,7 +1,7 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; -import { getAddressBook } from '../../../selectors'; +import { getAddressBook, getInternalAccounts } from '../../../selectors'; import { CONTACT_ADD_ROUTE, @@ -28,6 +28,7 @@ const mapStateToProps = (state, ownProps) => { editingContact, addingContact, addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), selectedAddress: pathNameTailIsAddress ? pathNameTail : '', hideAddressBook, currentPath: pathname, diff --git a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js index 06cb6a153c5f..0cfe7be2dd5b 100644 --- a/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js +++ b/ui/pages/settings/contact-list-tab/contact-list-tab.stories.js @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import configureStore from '../../../store/store'; import testData from '../../../../.storybook/test-data'; +import { getInternalAccounts } from '../../../selectors'; import ContactListTab from './contact-list-tab.component'; // Using Test Data For Redux @@ -14,6 +15,7 @@ export default { decorators: [(story) => {story()}], argsTypes: { addressBook: { control: 'object' }, + internalAccounts: { control: 'object' }, hideAddressBook: { control: 'boolean' }, selectedAddress: { control: 'select' }, history: { action: 'history' }, @@ -23,6 +25,8 @@ export default { const { metamask } = store.getState(); const { addresses } = metamask; +const internalAccounts = getInternalAccounts(store.getState()); + export const DefaultStory = (args) => { return (
@@ -34,6 +38,7 @@ export const DefaultStory = (args) => { DefaultStory.storyName = 'Default'; DefaultStory.args = { addressBook: addresses, + internalAccounts, hideAddressBook: false, selectedAddress: addresses.map(({ address }) => address), }; diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js index 335c5166da05..afb7efb1cc01 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.component.js @@ -21,6 +21,7 @@ import { Display, TextVariant, } from '../../../../helpers/constants/design-system'; +import { isDuplicateContact } from '../../../../components/app/contact-list/utils'; export default class EditContact extends PureComponent { static contextTypes = { @@ -28,6 +29,8 @@ export default class EditContact extends PureComponent { }; static propTypes = { + addressBook: PropTypes.array, + internalAccounts: PropTypes.array, addToAddressBook: PropTypes.func, removeFromAddressBook: PropTypes.func, history: PropTypes.object, @@ -48,7 +51,30 @@ export default class EditContact extends PureComponent { newName: this.props.name, newAddress: this.props.address, newMemo: this.props.memo, - error: '', + nameError: '', + addressError: '', + }; + + validateName = (newName) => { + if (newName === this.props.name) { + return true; + } + + const { addressBook, internalAccounts } = this.props; + + return !isDuplicateContact(addressBook, internalAccounts, newName); + }; + + handleNameChange = (e) => { + const newName = e.target.value; + + const isValidName = this.validateName(newName); + + this.setState({ + nameError: isValidName ? null : this.context.t('nameAlreadyInUse'), + }); + + this.setState({ newName }); }; render() { @@ -118,9 +144,10 @@ export default class EditContact extends PureComponent { id="nickname" placeholder={this.context.t('addAlias')} value={this.state.newName} - onChange={(e) => this.setState({ newName: e.target.value })} + onChange={this.handleNameChange} fullWidth margin="dense" + error={this.state.nameError} />
@@ -132,7 +159,7 @@ export default class EditContact extends PureComponent { type="text" id="address" value={this.state.newAddress} - error={this.state.error} + error={this.state.addressError} onChange={(e) => this.setState({ newAddress: e.target.value })} fullWidth multiline @@ -189,7 +216,9 @@ export default class EditContact extends PureComponent { ); history.push(listRoute); } else { - this.setState({ error: this.context.t('invalidAddress') }); + this.setState({ + addressError: this.context.t('invalidAddress'), + }); } } else { // update name @@ -205,12 +234,13 @@ export default class EditContact extends PureComponent { history.push(`${viewRoute}/${address}`); }} submitText={this.context.t('save')} - disabled={ + disabled={Boolean( (this.state.newName === name && this.state.newAddress === address && this.state.newMemo === memo) || - !this.state.newName.trim() - } + !this.state.newName.trim() || + this.state.nameError, + )} />
); diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js index af248b04d330..ff10d850345e 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.container.js @@ -2,8 +2,10 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { + getAddressBook, getAddressBookEntry, getInternalAccountByAddress, + getInternalAccounts, } from '../../../../selectors'; import { getProviderConfig } from '../../../../ducks/metamask/metamask'; import { @@ -34,6 +36,8 @@ const mapStateToProps = (state, ownProps) => { return { address: contact ? address : null, + addressBook: getAddressBook(state), + internalAccounts: getInternalAccounts(state), chainId, name, memo, diff --git a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js index 779416140e10..958385d2c79a 100644 --- a/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js +++ b/ui/pages/settings/contact-list-tab/edit-contact/edit-contact.test.js @@ -4,6 +4,8 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { renderWithProvider } from '../../../../../test/lib/render-helpers'; import '@testing-library/jest-dom/extend-expect'; +import { MOCK_ADDRESS_BOOK } from '../../../../../test/data/mock-data'; +import { createMockInternalAccount } from '../../../../../test/jest/mocks'; import EditContact from './edit-contact.component'; describe('AddContact component', () => { @@ -11,11 +13,17 @@ describe('AddContact component', () => { const state = { metamask: {}, }; + + const mockAccount1 = createMockInternalAccount(); + const mockAccount2 = createMockInternalAccount({ name: 'Test Contact' }); + const props = { + addressBook: MOCK_ADDRESS_BOOK, + internalAccounts: [mockAccount1, mockAccount2], addToAddressBook: jest.fn(), removeFromAddressBook: jest.fn(), history: { push: jest.fn() }, - name: '', + name: mockAccount1.metadata.name, address: '0x0000000000000000001', chainId: '', memo: '', @@ -36,11 +44,14 @@ describe('AddContact component', () => { const store = configureMockStore(middleware)(state); const { getByText } = renderWithProvider(, store); - const input = document.getElementById('address'); - fireEvent.change(input, { target: { value: 'invalid address' } }); - setTimeout(() => { - expect(getByText('Invalid address')).toBeInTheDocument(); - }, 100); + const addressInput = document.getElementById('address'); + fireEvent.change(addressInput, { target: { value: 'invalid address' } }); + + const submitButton = getByText('Save'); + + fireEvent.click(submitButton); + + expect(getByText('Invalid address')).toBeInTheDocument(); }); it('should get disabled submit button when username field is empty', () => { @@ -53,4 +64,46 @@ describe('AddContact component', () => { const saveButton = getByText('Save'); expect(saveButton).toBeDisabled(); }); + + it('should display error when entering a name that is in use by an existing contact', () => { + const store = configureMockStore(middleware)(state); + const { getByText } = renderWithProvider(, store); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: MOCK_ADDRESS_BOOK[0].name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(getByText('Name is already in use')).toBeDefined(); + }); + + it('should display error when entering a name that is in use by an existing account', () => { + const store = configureMockStore(middleware)(state); + const { getByText } = renderWithProvider(, store); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: mockAccount2.metadata.name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(getByText('Name is already in use')).toBeDefined(); + }); + + it('should not display error when entering the current contact name', () => { + const store = configureMockStore(middleware)(state); + const { getByText, queryByText } = renderWithProvider( + , + store, + ); + + const input = document.getElementById('nickname'); + fireEvent.change(input, { target: { value: mockAccount1.metadata.name } }); + + const saveButton = getByText('Save'); + + expect(saveButton).toBeDisabled(); + expect(queryByText('Name is already in use')).toBeNull(); + }); }); diff --git a/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts b/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts new file mode 100644 index 000000000000..93460c892d7f --- /dev/null +++ b/ui/pages/swaps/hooks/useSwapsFeatureFlags.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { fetchSwapsLivenessAndFeatureFlags } from '../../../ducks/swaps/swaps'; + +export function useSwapsFeatureFlags() { + const dispatch = useDispatch(); + + useEffect(() => { + const fetchSwapsLivenessAndFeatureFlagsWrapper = async () => { + await dispatch(fetchSwapsLivenessAndFeatureFlags()); + }; + fetchSwapsLivenessAndFeatureFlagsWrapper(); + }, [dispatch]); +} diff --git a/ui/pages/swaps/swaps.util.ts b/ui/pages/swaps/swaps.util.ts index 9cbf0b67a867..7065c7ae90dc 100644 --- a/ui/pages/swaps/swaps.util.ts +++ b/ui/pages/swaps/swaps.util.ts @@ -823,7 +823,7 @@ export const getSwap1559GasFeeEstimates = async ( }; }; -async function getTransaction1559GasFeeEstimates( +export async function getTransaction1559GasFeeEstimates( transactionParams: TransactionParams, estimatedBaseFee: Hex, chainId: Hex, diff --git a/ui/selectors/metamask-notifications/profile-syncing.test.ts b/ui/selectors/metamask-notifications/profile-syncing.test.ts index 946ffd3f0b5f..d05512e59523 100644 --- a/ui/selectors/metamask-notifications/profile-syncing.test.ts +++ b/ui/selectors/metamask-notifications/profile-syncing.test.ts @@ -5,6 +5,8 @@ describe('Profile Syncing Selectors', () => { metamask: { isProfileSyncingEnabled: true, isProfileSyncingUpdateLoading: false, + isAccountSyncingReadyToBeDispatched: false, + hasAccountSyncingSyncedAtLeastOnce: false, }, }; diff --git a/ui/selectors/metamask-notifications/profile-syncing.ts b/ui/selectors/metamask-notifications/profile-syncing.ts index 8b9b8f4997d6..ae219f47be68 100644 --- a/ui/selectors/metamask-notifications/profile-syncing.ts +++ b/ui/selectors/metamask-notifications/profile-syncing.ts @@ -2,7 +2,9 @@ import { createSelector } from 'reselect'; import type { UserStorageController } from '@metamask/profile-sync-controller'; type AppState = { - metamask: UserStorageController.UserStorageControllerState; + metamask: UserStorageController.UserStorageControllerState & { + hasFinishedAddingAccountsWithBalance?: boolean; + }; }; const getMetamask = (state: AppState) => state.metamask; @@ -36,3 +38,20 @@ export const selectIsProfileSyncingUpdateLoading = createSelector( return metamask.isProfileSyncingUpdateLoading; }, ); + +/** + * Selector to determine if account syncing is ready to be dispatched. This is set to true after all operations adding accounts are completed. + * This is needed for account syncing in order to prevent conflicts with accounts that are being added by the above method during onboarding. + * + * This selector uses the `createSelector` function from 'reselect' to compute whether the update process for profile syncing is currently in a loading state, + * based on the `hasFinishedAddingAccountsWithBalance` property of the `metamask` object in the Redux store. + * + * @param {AppState} state - The current state of the Redux store. + * @returns {boolean} Returns true if the profile syncing update is loading, false otherwise. + */ +export const selectIsAccountSyncingReadyToBeDispatched = createSelector( + [getMetamask], + (metamask) => { + return metamask.isAccountSyncingReadyToBeDispatched; + }, +); diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 96266687b6df..08078be52f03 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -23,6 +23,7 @@ import { MultichainNativeAssets } from '../../shared/constants/multichain/assets import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, TEST_NETWORK_IDS, + CHAIN_IDS, } from '../../shared/constants/network'; import { AccountsState, getSelectedInternalAccount } from './accounts'; import { @@ -316,6 +317,10 @@ export function getMultichainCurrentChainId(state: MultichainState) { return chainId; } +export function isChainIdMainnet(chainId: string) { + return chainId === CHAIN_IDS.MAINNET; +} + export function getMultichainIsMainnet( state: MultichainState, account?: InternalAccount, @@ -369,6 +374,13 @@ function getBtcCachedBalance(state: MultichainState) { return balances?.[account.id]?.[asset]?.amount; } +export function getImageForChainId(chainId: string) { + const evmChainIdKey = + chainId as keyof typeof CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP; + + return CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP[evmChainIdKey]; +} + // This selector is not compatible with `useMultichainSelector` since it uses the selected // account implicitly! export function getMultichainSelectedAccountCachedBalance( diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index c4f2928d8ef2..3c49befb6dd3 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -449,6 +449,21 @@ export function getMetaMaskCachedBalances(state) { return {}; } +export function getCrossChainMetaMaskCachedBalances(state) { + const allAccountsByChainId = state.metamask.accountsByChainId; + return Object.keys(allAccountsByChainId).reduce((acc, topLevelKey) => { + acc[topLevelKey] = Object.keys(allAccountsByChainId[topLevelKey]).reduce( + (innerAcc, innerKey) => { + innerAcc[innerKey] = + allAccountsByChainId[topLevelKey][innerKey].balance; + return innerAcc; + }, + {}, + ); + + return acc; + }, {}); +} /** * @typedef {import('./selectors.types').InternalAccountWithBalance} InternalAccountWithBalance */ @@ -568,7 +583,6 @@ export function getTargetAccount(state, targetAddress) { export const getTokenExchangeRates = (state) => { const chainId = getCurrentChainId(state); const contractMarketData = state.metamask.marketData?.[chainId] ?? {}; - return Object.entries(contractMarketData).reduce( (acc, [address, marketData]) => { acc[address] = marketData?.price ?? null; @@ -578,6 +592,22 @@ export const getTokenExchangeRates = (state) => { ); }; +export const getCrossChainTokenExchangeRates = (state) => { + const contractMarketData = state.metamask.marketData ?? {}; + + return Object.keys(contractMarketData).reduce((acc, topLevelKey) => { + acc[topLevelKey] = Object.keys(contractMarketData[topLevelKey]).reduce( + (innerAcc, innerKey) => { + innerAcc[innerKey] = contractMarketData[topLevelKey][innerKey]?.price; + return innerAcc; + }, + {}, + ); + + return acc; + }, {}); +}; + /** * Get market data for tokens on the current chain * @@ -954,6 +984,23 @@ export function getPetnamesEnabled(state) { return petnamesEnabled; } +export function getIsTokenNetworkFilterEqualCurrentNetwork(state) { + const chainId = getCurrentChainId(state); + const { tokenNetworkFilter: tokenNetworkFilterValue } = getPreferences(state); + const tokenNetworkFilter = tokenNetworkFilterValue || {}; + if ( + Object.keys(tokenNetworkFilter).length === 1 && + Object.keys(tokenNetworkFilter)[0] === chainId + ) { + return true; + } + return false; +} + +export function getUseTransactionSimulations(state) { + return Boolean(state.metamask.useTransactionSimulations); +} + export function getRedesignedConfirmationsEnabled(state) { const { redesignedConfirmationsEnabled } = getPreferences(state); return redesignedConfirmationsEnabled; @@ -1299,6 +1346,10 @@ export function getOriginOfCurrentTab(state) { return state.activeTab.origin; } +export function getDefaultHomeActiveTabName(state) { + return state.metamask.defaultHomeActiveTabName; +} + export function getIpfsGateway(state) { return state.metamask.ipfsGateway; } @@ -2188,6 +2239,48 @@ export const getAllEnabledNetworks = createDeepEqualSelector( ), ); +export const getChainIdsToPoll = createDeepEqualSelector( + getNetworkConfigurationsByChainId, + getCurrentChainId, + (networkConfigurations, currentChainId) => { + if (!process.env.PORTFOLIO_VIEW) { + return [currentChainId]; + } + + return Object.keys(networkConfigurations).filter( + (chainId) => !TEST_CHAINS.includes(chainId), + ); + }, +); + +export const getNetworkClientIdsToPoll = createDeepEqualSelector( + getNetworkConfigurationsByChainId, + getCurrentChainId, + (networkConfigurations, currentChainId) => { + if (!process.env.PORTFOLIO_VIEW) { + const networkConfiguration = networkConfigurations[currentChainId]; + return [ + networkConfiguration.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex + ].networkClientId, + ]; + } + + return Object.entries(networkConfigurations).reduce( + (acc, [chainId, network]) => { + if (!TEST_CHAINS.includes(chainId)) { + acc.push( + network.rpcEndpoints[network.defaultRpcEndpointIndex] + .networkClientId, + ); + } + return acc; + }, + [], + ); + }, +); + /** * To retrieve the maxBaseFee and priorityFee the user has set as default * diff --git a/ui/selectors/selectors.test.js b/ui/selectors/selectors.test.js index d6656e481709..d3799885eaf6 100644 --- a/ui/selectors/selectors.test.js +++ b/ui/selectors/selectors.test.js @@ -838,6 +838,97 @@ describe('Selectors', () => { }); }); + describe('#getChainIdsToPoll', () => { + const networkConfigurationsByChainId = { + [CHAIN_IDS.MAINNET]: { + chainId: CHAIN_IDS.MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + chainId: CHAIN_IDS.LINEA_MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-mainnet' }], + }, + [CHAIN_IDS.SEPOLIA]: { + chainId: CHAIN_IDS.SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'sepolia' }], + }, + [CHAIN_IDS.LINEA_SEPOLIA]: { + chainId: CHAIN_IDS.LINEA_SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-sepolia' }], + }, + }; + + beforeEach(() => { + process.env.PORTFOLIO_VIEW = 'true'; + }); + + afterEach(() => { + process.env.PORTFOLIO_VIEW = undefined; + }); + + it('returns only non-test chain IDs', () => { + const chainIds = selectors.getChainIdsToPoll({ + metamask: { + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(2); + expect(chainIds).toStrictEqual([ + CHAIN_IDS.MAINNET, + CHAIN_IDS.LINEA_MAINNET, + ]); + }); + }); + + describe('#getNetworkClientIdsToPoll', () => { + const networkConfigurationsByChainId = { + [CHAIN_IDS.MAINNET]: { + chainId: CHAIN_IDS.MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'mainnet' }], + }, + [CHAIN_IDS.LINEA_MAINNET]: { + chainId: CHAIN_IDS.LINEA_MAINNET, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-mainnet' }], + }, + [CHAIN_IDS.SEPOLIA]: { + chainId: CHAIN_IDS.SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'sepolia' }], + }, + [CHAIN_IDS.LINEA_SEPOLIA]: { + chainId: CHAIN_IDS.LINEA_SEPOLIA, + defaultRpcEndpointIndex: 0, + rpcEndpoints: [{ networkClientId: 'linea-sepolia' }], + }, + }; + + beforeEach(() => { + process.env.PORTFOLIO_VIEW = 'true'; + }); + + afterEach(() => { + process.env.PORTFOLIO_VIEW = undefined; + }); + + it('returns only non-test chain IDs', () => { + const chainIds = selectors.getNetworkClientIdsToPoll({ + metamask: { + networkConfigurationsByChainId, + selectedNetworkClientId: 'mainnet', + }, + }); + expect(Object.values(chainIds)).toHaveLength(2); + expect(chainIds).toStrictEqual(['mainnet', 'linea-mainnet']); + }); + }); + describe('#isHardwareWallet', () => { it('returns false if it is not a HW wallet', () => { const mockStateWithImported = modifyStateWithHWKeyring( diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 22e8db2fa281..10391adad3df 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -4,6 +4,7 @@ import thunk from 'redux-thunk'; import { EthAccountType } from '@metamask/keyring-api'; import { TransactionStatus } from '@metamask/transaction-controller'; import { NotificationServicesController } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import enLocale from '../../app/_locales/en/messages.json'; @@ -2605,7 +2606,9 @@ describe('Actions', () => { await store.dispatch(actions.deleteAccountSyncingDataFromUserStorage()); expect( - deleteAccountSyncingDataFromUserStorageStub.calledOnceWith('accounts'), + deleteAccountSyncingDataFromUserStorageStub.calledOnceWith( + USER_STORAGE_FEATURE_NAMES.accounts, + ), ).toBe(true); }); }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 67de497817c8..01c34dc2fe3d 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -42,6 +42,7 @@ import { import { InterfaceState } from '@metamask/snaps-sdk'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { NotificationServicesController } from '@metamask/notification-services-controller'; +import { USER_STORAGE_FEATURE_NAMES } from '@metamask/profile-sync-controller/sdk'; import { Patch } from 'immer'; import { HandlerType } from '@metamask/snaps-utils'; import switchDirection from '../../shared/lib/switch-direction'; @@ -2447,7 +2448,12 @@ export function createRetryTransaction( export function addNetwork( networkConfiguration: AddNetworkFields | UpdateNetworkFields, -): ThunkAction, MetaMaskReduxState, unknown, AnyAction> { +): ThunkAction< + Promise, + MetaMaskReduxState, + unknown, + AnyAction +> { return async (dispatch: MetaMaskReduxDispatch) => { log.debug(`background.addNetwork`, networkConfiguration); try { @@ -3548,13 +3554,14 @@ export function setIpfsGateway( export function toggleExternalServices( val: boolean, ): ThunkAction { - return (dispatch: MetaMaskReduxDispatch) => { + return async (dispatch: MetaMaskReduxDispatch) => { log.debug(`background.toggleExternalServices`); - callBackgroundMethod('toggleExternalServices', [val], (err) => { - if (err) { - dispatch(displayWarning(err)); - } - }); + try { + await submitRequestToBackground('toggleExternalServices', [val]); + await forceUpdateMetamaskState(dispatch); + } catch (err) { + dispatch(displayWarning(err)); + } }; } @@ -4564,6 +4571,86 @@ export async function currencyRateStopPollingByPollingToken( await removePollingTokenFromAppState(pollingToken); } +/** + * Informs the TokenDetectionController that the UI requires token detection polling + * + * @param chainIds - An array of chain ids to poll token detection on. + * @returns polling token that can be used to stop polling. + */ +export async function tokenDetectionStartPolling( + chainIds: string[], +): Promise { + const pollingToken = await submitRequestToBackground( + 'tokenDetectionStartPolling', + [{ chainIds }], + ); + + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +/** + * Informs the TokenDetectionController that the UI no longer token detection polling + * + * @param pollingToken - Poll token received from calling tokenDetectionStartPolling + */ +export async function tokenDetectionStopPollingByPollingToken( + pollingToken: string, +) { + await submitRequestToBackground('tokenDetectionStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + +/** + * Informs the TokenListController that the UI requires token list polling + * + * @param chainId + * @returns polling token that can be used to stop polling + */ +export async function tokenListStartPolling(chainId: string): Promise { + const pollingToken = await submitRequestToBackground( + 'tokenListStartPolling', + [{ chainId }], + ); + + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +/** + * Informs the TokenListController that the UI no longer token list polling + * + * @param pollingToken - Poll token received from calling tokenListStartPolling + */ +export async function tokenListStopPollingByPollingToken(pollingToken: string) { + await submitRequestToBackground('tokenListStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + +export async function tokenBalancesStartPolling( + chainId: string, +): Promise { + const pollingToken = await submitRequestToBackground( + 'tokenBalancesStartPolling', + [{ chainId }], + ); + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +export async function tokenBalancesStopPollingByPollingToken( + pollingToken: string, +) { + await submitRequestToBackground('tokenBalancesStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + /** * Informs the TokenRatesController that the UI requires * token rate polling for the given chain id. @@ -4595,6 +4682,37 @@ export async function tokenRatesStopPollingByPollingToken( await removePollingTokenFromAppState(pollingToken); } +/** + * Starts polling on accountTrackerController with the networkClientId + * + * @param networkClientId - The network client ID to pull balances for. + * @returns polling token used to stop polling + */ +export async function accountTrackerStartPolling( + networkClientId: string, +): Promise { + const pollingToken = await submitRequestToBackground( + 'accountTrackerStartPolling', + [networkClientId], + ); + await addPollingTokenToAppState(pollingToken); + return pollingToken; +} + +/** + * Stops polling on the account tracker controller. + * + * @param pollingToken - polling token to use to stop polling. + */ +export async function accountTrackerStopPollingByPollingToken( + pollingToken: string, +) { + await submitRequestToBackground('accountTrackerStopPollingByPollingToken', [ + pollingToken, + ]); + await removePollingTokenFromAppState(pollingToken); +} + /** * Informs the GasFeeController that the UI requires gas fee polling * @@ -5549,7 +5667,7 @@ export function deleteAccountSyncingDataFromUserStorage(): ThunkAction< try { const response = await submitRequestToBackground( 'deleteAccountSyncingDataFromUserStorage', - ['accounts'], + [USER_STORAGE_FEATURE_NAMES.accounts], ); return response; } catch (error) { diff --git a/yarn.lock b/yarn.lock index bb91a8129ac2..527af97c08f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2323,7 +2323,7 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/rlp@npm:^4.0.0, @ethereumjs/rlp@npm:^4.0.1": +"@ethereumjs/rlp@npm:^4.0.1": version: 4.0.1 resolution: "@ethereumjs/rlp@npm:4.0.1" bin: @@ -2398,7 +2398,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/abi@npm:5.7.0, @ethersproject/abi@npm:^5.6.4, @ethersproject/abi@npm:^5.7.0": +"@ethersproject/abi@npm:5.7.0, @ethersproject/abi@npm:^5.5.0, @ethersproject/abi@npm:^5.6.4, @ethersproject/abi@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/abi@npm:5.7.0" dependencies: @@ -2700,7 +2700,7 @@ __metadata: languageName: node linkType: hard -"@ethersproject/rlp@npm:5.7.0, @ethersproject/rlp@npm:^5.7.0": +"@ethersproject/rlp@npm:5.7.0, @ethersproject/rlp@npm:^5.5.0, @ethersproject/rlp@npm:^5.7.0": version: 5.7.0 resolution: "@ethersproject/rlp@npm:5.7.0" dependencies: @@ -4265,6 +4265,168 @@ __metadata: languageName: node linkType: hard +"@ledgerhq/cryptoassets-evm-signatures@npm:^13.5.0": + version: 13.5.0 + resolution: "@ledgerhq/cryptoassets-evm-signatures@npm:13.5.0" + dependencies: + "@ledgerhq/live-env": "npm:^2.3.0" + axios: "npm:1.7.7" + checksum: 10/ce6e3343fdf60255ede1d784a2fb47c8c5f49e8257559947e2678fac250700140695c85ae06bef777ccc4bb37577db813a4b3d031f3b0b84cbcd6e139613fbf9 + languageName: node + linkType: hard + +"@ledgerhq/devices@npm:^8.4.4": + version: 8.4.4 + resolution: "@ledgerhq/devices@npm:8.4.4" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + rxjs: "npm:^7.8.1" + semver: "npm:^7.3.5" + checksum: 10/57136fc45ae2fa42b3cf93eb7cc3542fd84010390b3d0a536d342c7e92f90e475d608b1774f17a547419edddd7df0d0b1b1dbd6d2c778009ebab0fc3ec313f67 + languageName: node + linkType: hard + +"@ledgerhq/domain-service@npm:^1.2.6": + version: 1.2.6 + resolution: "@ledgerhq/domain-service@npm:1.2.6" + dependencies: + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.52.0" + axios: "npm:1.7.7" + eip55: "npm:^2.1.1" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" + checksum: 10/0b680af9deff24f608446cb7026b06e09ea693a367a5ef5e71068c70fabfbb98019cb618daefed278ed875a0ff5d296735fb743746ac0edee1357ae12fd58d99 + languageName: node + linkType: hard + +"@ledgerhq/errors@npm:^6.19.1": + version: 6.19.1 + resolution: "@ledgerhq/errors@npm:6.19.1" + checksum: 10/8265c6d73c314a4aabbe060ec29e2feebb4e904fe811bf7a9c53cde08e713dcbceded9d927ebb2f0ffc47a7b16524379d4a7e9aa3d61945b8a832be7cd5cf69b + languageName: node + linkType: hard + +"@ledgerhq/evm-tools@npm:1.2.3": + version: 1.2.3 + resolution: "@ledgerhq/evm-tools@npm:1.2.3" + dependencies: + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.0" + "@ledgerhq/live-env": "npm:^2.3.0" + axios: "npm:1.7.7" + crypto-js: "npm:4.2.0" + ethers: "npm:5.7.2" + checksum: 10/956a0a3ac26454ac350c5e34b5cfb911ed3a0f3f724bec1ce2c56f2de344635b582f977e4901841c353d98c50b076e40bfb03d865eed3a82e328744043e00ee7 + languageName: node + linkType: hard + +"@ledgerhq/evm-tools@patch:@ledgerhq/evm-tools@npm%3A1.2.3#~/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch": + version: 1.2.3 + resolution: "@ledgerhq/evm-tools@patch:@ledgerhq/evm-tools@npm%3A1.2.3#~/.yarn/patches/@ledgerhq-evm-tools-npm-1.2.3-414f44baa9.patch::version=1.2.3&hash=1deaa9" + dependencies: + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.0" + "@ledgerhq/live-env": "npm:^2.3.0" + axios: "npm:1.7.7" + crypto-js: "npm:4.2.0" + ethers: "npm:5.7.2" + checksum: 10/c7d79c691f681376efc6c44d16a00f6349301091a82a7a8ad264475562896bf8a9b4e922080f4f6f541bb62483ad9efeeec87910fa6f15d9db3e1d22820548c8 + languageName: node + linkType: hard + +"@ledgerhq/hw-app-eth@npm:6.39.0": + version: 6.39.0 + resolution: "@ledgerhq/hw-app-eth@npm:6.39.0" + dependencies: + "@ethersproject/abi": "npm:^5.5.0" + "@ethersproject/rlp": "npm:^5.5.0" + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.0" + "@ledgerhq/domain-service": "npm:^1.2.6" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/evm-tools": "npm:^1.2.3" + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@ledgerhq/hw-transport-mocker": "npm:^6.29.4" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.52.0" + axios: "npm:1.7.7" + bignumber.js: "npm:^9.1.2" + semver: "npm:^7.3.5" + checksum: 10/5b50aac35989e09704557523efe5b6b29a1f31f5279088ecceb90164e32e05860f3ff20c32a084003a69723ed08165ae140c2987424657245f088c3f9a908cd6 + languageName: node + linkType: hard + +"@ledgerhq/hw-app-eth@patch:@ledgerhq/hw-app-eth@npm%3A6.39.0#~/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch": + version: 6.39.0 + resolution: "@ledgerhq/hw-app-eth@patch:@ledgerhq/hw-app-eth@npm%3A6.39.0#~/.yarn/patches/@ledgerhq-hw-app-eth-npm-6.39.0-866309bbbe.patch::version=6.39.0&hash=f5404c" + dependencies: + "@ethersproject/abi": "npm:^5.5.0" + "@ethersproject/rlp": "npm:^5.5.0" + "@ledgerhq/cryptoassets-evm-signatures": "npm:^13.5.0" + "@ledgerhq/domain-service": "npm:^1.2.6" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/evm-tools": "npm:^1.2.3" + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@ledgerhq/hw-transport-mocker": "npm:^6.29.4" + "@ledgerhq/logs": "npm:^6.12.0" + "@ledgerhq/types-live": "npm:^6.52.0" + axios: "npm:1.7.7" + bignumber.js: "npm:^9.1.2" + semver: "npm:^7.3.5" + checksum: 10/fb270eee2b2d0886462908b4c511262da0c884f0b72ab50225407608d197ece71b05b4fdc07e628c75ab3ae573e49ed5c3ccf3b2f40a28e4663a28788d46b4dd + languageName: node + linkType: hard + +"@ledgerhq/hw-transport-mocker@npm:^6.29.4": + version: 6.29.4 + resolution: "@ledgerhq/hw-transport-mocker@npm:6.29.4" + dependencies: + "@ledgerhq/hw-transport": "npm:^6.31.4" + "@ledgerhq/logs": "npm:^6.12.0" + rxjs: "npm:^7.8.1" + checksum: 10/6f1568b1723ee6964872b09b712714bacf33c87e83413a33420b7ba11e3c30fa6786f02d2cf7b8bc9b3560f4b5c3b166017d5e0a960267a7824a153687fe32ed + languageName: node + linkType: hard + +"@ledgerhq/hw-transport@npm:^6.31.4": + version: 6.31.4 + resolution: "@ledgerhq/hw-transport@npm:6.31.4" + dependencies: + "@ledgerhq/devices": "npm:^8.4.4" + "@ledgerhq/errors": "npm:^6.19.1" + "@ledgerhq/logs": "npm:^6.12.0" + events: "npm:^3.3.0" + checksum: 10/cf101e5b818e95e59031241d556dbec24658f54104910e414be493bc4b90b0aea50f5d4b3339a237dd0b12845bb2683c845f3a82f2ea9da4e077b68d1e1f7e48 + languageName: node + linkType: hard + +"@ledgerhq/live-env@npm:^2.3.0": + version: 2.3.0 + resolution: "@ledgerhq/live-env@npm:2.3.0" + dependencies: + rxjs: "npm:^7.8.1" + utility-types: "npm:^3.10.0" + checksum: 10/757ff834d6b94dce0487d60ab0efa45ef206ebbdfd29053c232b4c0fdce1594016531340c1603695b37f17deb1639dcb98078a9629eeb36c835a19f4180834ce + languageName: node + linkType: hard + +"@ledgerhq/logs@npm:^6.12.0": + version: 6.12.0 + resolution: "@ledgerhq/logs@npm:6.12.0" + checksum: 10/a0a01f5d6edb0c14e7a42d24ab67ce362219517f6a50d0572c917f4f7988a1e2e9363e3d0fb170fe267f054e1e30a111564de44276e01c538b258d902c546421 + languageName: node + linkType: hard + +"@ledgerhq/types-live@npm:^6.52.0": + version: 6.52.0 + resolution: "@ledgerhq/types-live@npm:6.52.0" + dependencies: + bignumber.js: "npm:^9.1.2" + rxjs: "npm:^7.8.1" + checksum: 10/c410f02159538d66f59956512fc5bab2cb17edee7f6a15a517c31d89d6c730e52691666bb2e1d98718c701e94306dd7498544ea3d7772ff0b5ad6522fb2c335c + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.4 resolution: "@leichtgewicht/ip-codec@npm:2.0.4" @@ -4772,11 +4934,12 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:42.0.0": - version: 42.0.0 - resolution: "@metamask/assets-controllers@npm:42.0.0" +"@metamask/assets-controllers@npm:44.0.0": + version: 44.0.0 + resolution: "@metamask/assets-controllers@npm:44.0.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -4784,7 +4947,7 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/base-controller": "npm:^7.0.2" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/polling-controller": "npm:^12.0.1" @@ -4801,20 +4964,21 @@ __metadata: single-call-balance-checker-abi: "npm:^1.0.0" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^18.0.0 + "@metamask/accounts-controller": ^19.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^17.0.0 + "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/preferences-controller": ^13.0.0 - checksum: 10/64d2bd43139ee5c19bd665b07212cd5d5dd41b457dedde3b5db31442292c4d064dc015011f5f001bb423683675fb20898ff652e91d2339ad1d21cc45fa93487a + "@metamask/preferences-controller": ^14.0.0 + checksum: 10/6f3d8712a90aa322aabd38d43663d299ad7ee98a6d838d72bfc3b426ea0e4e925bb78c1aaaa3c75d43e95d46993c47583a4a03f4c58aee155525424fa86207ae languageName: node linkType: hard -"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch": - version: 42.0.0 - resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch::version=42.0.0&hash=e14ff8" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2": + version: 44.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch::version=44.0.0&hash=5a94c2" dependencies: "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/address": "npm:^5.7.0" "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" @@ -4822,7 +4986,7 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/base-controller": "npm:^7.0.2" "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/polling-controller": "npm:^12.0.1" @@ -4839,12 +5003,51 @@ __metadata: single-call-balance-checker-abi: "npm:^1.0.0" uuid: "npm:^8.3.2" peerDependencies: - "@metamask/accounts-controller": ^18.0.0 + "@metamask/accounts-controller": ^19.0.0 "@metamask/approval-controller": ^7.0.0 - "@metamask/keyring-controller": ^17.0.0 + "@metamask/keyring-controller": ^18.0.0 + "@metamask/network-controller": ^22.0.0 + "@metamask/preferences-controller": ^14.0.0 + checksum: 10/0d6c386a1f1e68ab339340fd8fa600827f55f234bc54b2224069a1819ab037641daa9696a0d62f187c0649317393efaeeb119a7852af51da3bb340e0e98cf9f6 + languageName: node + linkType: hard + +"@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch": + version: 44.0.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch::version=44.0.0&hash=c4e407" + dependencies: + "@ethereumjs/util": "npm:^8.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.3" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/polling-controller": "npm:^12.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/utils": "npm:^10.0.0" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bn.js: "npm:^5.2.1" + cockatiel: "npm:^3.1.2" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + multiformats: "npm:^13.1.0" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^19.0.0 + "@metamask/approval-controller": ^7.0.0 + "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 - "@metamask/preferences-controller": ^13.0.0 - checksum: 10/9a6727b28f88fd2df3f4b1628dd5d8c2f3e73fd4b9cd090f22d175c2522faa6c6b7e9a93d0ec2b2d123a263c8f4116fbfe97f196b99401b28ac8597f522651eb + "@metamask/preferences-controller": ^14.0.0 + checksum: 10/11e8920bdf8ffce4a534c6aadfe768176c4e461a00bc06e6ece52f085755ff252194881d9edd308097186a05057075fd9812b6e4b1fd97dd731814ad205013da languageName: node linkType: hard @@ -4925,9 +5128,9 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0, @metamask/controller-utils@npm:^11.4.1, @metamask/controller-utils@npm:^11.4.2": - version: 11.4.2 - resolution: "@metamask/controller-utils@npm:11.4.2" +"@metamask/controller-utils@npm:^11.0.0, @metamask/controller-utils@npm:^11.0.2, @metamask/controller-utils@npm:^11.1.0, @metamask/controller-utils@npm:^11.2.0, @metamask/controller-utils@npm:^11.3.0, @metamask/controller-utils@npm:^11.4.0, @metamask/controller-utils@npm:^11.4.1, @metamask/controller-utils@npm:^11.4.2, @metamask/controller-utils@npm:^11.4.3": + version: 11.4.3 + resolution: "@metamask/controller-utils@npm:11.4.3" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/eth-query": "npm:^4.0.0" @@ -4939,7 +5142,7 @@ __metadata: bn.js: "npm:^5.2.1" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - checksum: 10/fdae49ee97e7a2a1bb6414011ca59932f8712a768a9c4c43673a2504c9fa9e61d83df53a21ff0506ef6a8cf774704f2df58a6d71385c8786ec5cab4359c051e1 + checksum: 10/5703b0721daf679cf44affc690f2b313e40893b64b0aafaf203e69ee51438197cc3634ef7094145f580a8a8aaadcb79026b2fbd4065c1bb4a8c26627a2c4c69a languageName: node linkType: hard @@ -5055,16 +5258,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-block-tracker@npm:^11.0.1": - version: 11.0.1 - resolution: "@metamask/eth-block-tracker@npm:11.0.1" +"@metamask/eth-block-tracker@npm:^11.0.1, @metamask/eth-block-tracker@npm:^11.0.2": + version: 11.0.2 + resolution: "@metamask/eth-block-tracker@npm:11.0.2" dependencies: - "@metamask/eth-json-rpc-provider": "npm:^4.1.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" "@metamask/safe-event-emitter": "npm:^3.1.1" "@metamask/utils": "npm:^9.1.0" json-rpc-random-id: "npm:^1.0.1" pify: "npm:^5.0.0" - checksum: 10/6a5143dcd20ea87cd674efb25870275d97d4ffe921e843391a5b85876ebe074e5a587a128c268d27520904c74c9feecf91218ea086bd65cc6096f8501bdf8f32 + checksum: 10/11d22bd86056401aa41eff5a32e862f3644eaf03040d8aa54a95cb0c1dfd3e3ce7e650c25efabbe0954cc6ba5f92172c338b518df84f73c4601c4bbc960b588a languageName: node linkType: hard @@ -5107,6 +5310,18 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-json-rpc-infura@npm:^10.0.0": + version: 10.0.0 + resolution: "@metamask/eth-json-rpc-infura@npm:10.0.0" + dependencies: + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" + "@metamask/json-rpc-engine": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/utils": "npm:^9.1.0" + checksum: 10/17e0147ff86c48107983035e9bda4d16fba321ee0e29733347e9338a4c795c506a2ffd643c44c9d5334886696412cf288f852d06311fed0d76edc8847ee6b8de + languageName: node + linkType: hard + "@metamask/eth-json-rpc-infura@npm:^9.1.0": version: 9.1.0 resolution: "@metamask/eth-json-rpc-infura@npm:9.1.0" @@ -5158,6 +5373,25 @@ __metadata: languageName: node linkType: hard +"@metamask/eth-json-rpc-middleware@npm:^15.0.0": + version: 15.0.0 + resolution: "@metamask/eth-json-rpc-middleware@npm:15.0.0" + dependencies: + "@metamask/eth-block-tracker": "npm:^11.0.1" + "@metamask/eth-json-rpc-provider": "npm:^4.1.5" + "@metamask/eth-sig-util": "npm:^7.0.3" + "@metamask/json-rpc-engine": "npm:^10.0.0" + "@metamask/rpc-errors": "npm:^7.0.0" + "@metamask/utils": "npm:^9.1.0" + "@types/bn.js": "npm:^5.1.5" + bn.js: "npm:^5.2.1" + klona: "npm:^2.0.6" + pify: "npm:^5.0.0" + safe-stable-stringify: "npm:^2.4.3" + checksum: 10/3c48d34264c695535f2b4e819fb602d835b6ed37309116a06d04d1b706a7335e0205cd4ccdbf1d3e9dc15ebf40d88954a9a2dc18a91f223dcd6d6392e026a5e9 + languageName: node + linkType: hard + "@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch": version: 14.0.1 resolution: "@metamask/eth-json-rpc-middleware@patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch::version=14.0.1&hash=96e7e0" @@ -5199,29 +5433,30 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-json-rpc-provider@npm:^4.0.0, @metamask/eth-json-rpc-provider@npm:^4.1.0, @metamask/eth-json-rpc-provider@npm:^4.1.1, @metamask/eth-json-rpc-provider@npm:^4.1.3": - version: 4.1.3 - resolution: "@metamask/eth-json-rpc-provider@npm:4.1.3" +"@metamask/eth-json-rpc-provider@npm:^4.0.0, @metamask/eth-json-rpc-provider@npm:^4.1.0, @metamask/eth-json-rpc-provider@npm:^4.1.1, @metamask/eth-json-rpc-provider@npm:^4.1.3, @metamask/eth-json-rpc-provider@npm:^4.1.5, @metamask/eth-json-rpc-provider@npm:^4.1.6": + version: 4.1.6 + resolution: "@metamask/eth-json-rpc-provider@npm:4.1.6" dependencies: - "@metamask/json-rpc-engine": "npm:^9.0.2" - "@metamask/rpc-errors": "npm:^6.3.1" + "@metamask/json-rpc-engine": "npm:^10.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/safe-event-emitter": "npm:^3.0.0" - "@metamask/utils": "npm:^9.1.0" + "@metamask/utils": "npm:^10.0.0" uuid: "npm:^8.3.2" - checksum: 10/d581cc0f6485783ed59ac9517aa7f0eb37ee6a0674409eeaba1bbda4b54fcc5f633cc8ace66207871e2c2fac33195982969f4e61c18b04faf4656cccf79d8d3d + checksum: 10/aeec2c362a5386357e9f8c707da9baa4326e83889633723656b6801b6461ea8ab8f020b0d9ed0bbc2d8fd6add4af4c99cc9c9a1cbedca267a033a9f19da41200 languageName: node linkType: hard -"@metamask/eth-ledger-bridge-keyring@npm:^3.0.1": - version: 3.0.1 - resolution: "@metamask/eth-ledger-bridge-keyring@npm:3.0.1" +"@metamask/eth-ledger-bridge-keyring@npm:^5.0.1": + version: 5.0.1 + resolution: "@metamask/eth-ledger-bridge-keyring@npm:5.0.1" dependencies: - "@ethereumjs/rlp": "npm:^4.0.0" - "@ethereumjs/tx": "npm:^4.1.1" - "@ethereumjs/util": "npm:^8.0.0" - "@metamask/eth-sig-util": "npm:^7.0.0" + "@ethereumjs/rlp": "npm:^5.0.2" + "@ethereumjs/tx": "npm:^4.2.0" + "@ethereumjs/util": "npm:^8.1.0" + "@ledgerhq/hw-app-eth": "npm:^6.39.0" + "@metamask/eth-sig-util": "npm:^7.0.3" hdkey: "npm:^2.1.0" - checksum: 10/6643cca79fd6c1d2d65ff8affed1f8a776d1455025695396970605350b2cbfd14750c5fb1913d43552a61f9a369e0904a1accd46a9b8d771f7cd0196af6c1ae4 + checksum: 10/5ac967d7c88c2f1f2853118cfe65ee081a90c2633fb468eae8049c3a78a4985f03130bff3071e9fb893e4c97bc55aeb2196e2b6e9e363ac6235379b80e7cfc7e languageName: node linkType: hard @@ -5613,6 +5848,27 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-controller@npm:^18.0.0": + version: 18.0.0 + resolution: "@metamask/keyring-controller@npm:18.0.0" + dependencies: + "@ethereumjs/util": "npm:^8.1.0" + "@keystonehq/metamask-airgapped-keyring": "npm:^0.14.1" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/browser-passworder": "npm:^4.3.0" + "@metamask/eth-hd-keyring": "npm:^7.0.4" + "@metamask/eth-sig-util": "npm:^8.0.0" + "@metamask/eth-simple-keyring": "npm:^6.0.5" + "@metamask/keyring-api": "npm:^8.1.3" + "@metamask/message-manager": "npm:^11.0.1" + "@metamask/utils": "npm:^10.0.0" + async-mutex: "npm:^0.5.0" + ethereumjs-wallet: "npm:^1.0.1" + immer: "npm:^9.0.6" + checksum: 10/c301e4e8b9ac9da914bfaa371a43342aa37f5bb8ad107bbbd92f1d21a13c22351619f8bd6176493b808f4194aa9934bce5618ff0aed12325933f4330cdfd308e + languageName: node + linkType: hard + "@metamask/logging-controller@npm:^6.0.0": version: 6.0.0 resolution: "@metamask/logging-controller@npm:6.0.0" @@ -5708,6 +5964,31 @@ __metadata: languageName: node linkType: hard +"@metamask/network-controller@npm:^22.0.2": + version: 22.0.2 + resolution: "@metamask/network-controller@npm:22.0.2" + dependencies: + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/eth-block-tracker": "npm:^11.0.2" + "@metamask/eth-json-rpc-infura": "npm:^10.0.0" + "@metamask/eth-json-rpc-middleware": "npm:^15.0.0" + "@metamask/eth-json-rpc-provider": "npm:^4.1.6" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/json-rpc-engine": "npm:^10.0.1" + "@metamask/rpc-errors": "npm:^7.0.1" + "@metamask/swappable-obj-proxy": "npm:^2.2.0" + "@metamask/utils": "npm:^10.0.0" + async-mutex: "npm:^0.5.0" + immer: "npm:^9.0.6" + loglevel: "npm:^1.8.1" + reselect: "npm:^5.1.1" + uri-js: "npm:^4.4.1" + uuid: "npm:^8.3.2" + checksum: 10/9da27189a4263ef7fa4596ada2000d7f944bc3f4dea63a77cf6f8b2ea89412d499068cf0542785088d19437263bd0b3b3bb3299533f87439729ccd8ecee2b625 + languageName: node + linkType: hard + "@metamask/network-controller@patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch": version: 21.0.0 resolution: "@metamask/network-controller@patch:@metamask/network-controller@npm%3A21.0.0#~/.yarn/patches/@metamask-network-controller-npm-21.0.0-559aa8e395.patch::version=21.0.0&hash=1a5039" @@ -6006,13 +6287,14 @@ __metadata: languageName: node linkType: hard -"@metamask/profile-sync-controller@npm:^0.9.7": - version: 0.9.7 - resolution: "@metamask/profile-sync-controller@npm:0.9.7" +"@metamask/profile-sync-controller@npm:^1.0.2": + version: 1.0.2 + resolution: "@metamask/profile-sync-controller@npm:1.0.2" dependencies: - "@metamask/base-controller": "npm:^7.0.1" + "@metamask/base-controller": "npm:^7.0.2" "@metamask/keyring-api": "npm:^8.1.3" - "@metamask/keyring-controller": "npm:^17.2.2" + "@metamask/keyring-controller": "npm:^18.0.0" + "@metamask/network-controller": "npm:^22.0.2" "@metamask/snaps-sdk": "npm:^6.5.0" "@metamask/snaps-utils": "npm:^8.1.1" "@noble/ciphers": "npm:^0.5.2" @@ -6021,10 +6303,11 @@ __metadata: loglevel: "npm:^1.8.1" siwe: "npm:^2.3.2" peerDependencies: - "@metamask/accounts-controller": ^18.1.1 - "@metamask/keyring-controller": ^17.2.0 + "@metamask/accounts-controller": ^19.0.0 + "@metamask/keyring-controller": ^18.0.0 + "@metamask/network-controller": ^22.0.0 "@metamask/snaps-controllers": ^9.7.0 - checksum: 10/e53888533b2aae937bbe4e385dca2617c324b34e3e60af218cd98c26d514fb725f4c67b649f126e055f6a50a554817b229d37488115b98d70e8aee7b3a910bde + checksum: 10/e8ce9cc5749746bea3f6fb9207bbd4e8e3956f92447f3a6b790e3ba7203747e38b9a819f7a4f1896022cf6e1a065e6136a3c82ee83a4ec0ee56b23de27e23f03 languageName: node linkType: hard @@ -6149,12 +6432,12 @@ __metadata: languageName: node linkType: hard -"@metamask/signature-controller@npm:^21.0.0": - version: 21.0.0 - resolution: "@metamask/signature-controller@npm:21.0.0" +"@metamask/signature-controller@npm:^21.1.0": + version: 21.1.0 + resolution: "@metamask/signature-controller@npm:21.1.0" dependencies: "@metamask/base-controller": "npm:^7.0.2" - "@metamask/controller-utils": "npm:^11.4.1" + "@metamask/controller-utils": "npm:^11.4.2" "@metamask/eth-sig-util": "npm:^8.0.0" "@metamask/utils": "npm:^10.0.0" jsonschema: "npm:^1.2.4" @@ -6165,7 +6448,7 @@ __metadata: "@metamask/keyring-controller": ^17.0.0 "@metamask/logging-controller": ^6.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/4c1b1cbf909004099adb3f0d2b01c8fe640ae9a13a8e53ffbcf05c7a1a23384f6077b96b845c22c4edf3bceaaff2a705769d4623f37affac7e429ab0dae06912 + checksum: 10/00d28234d6402632ecf000d7c908a134a0f49cbbdb165a7cfe72895bc91248de82947cd9628dbbe953ffb8b6054f84a0dc1ad824a1aff369d8f2189a78fd56a9 languageName: node linkType: hard @@ -6177,9 +6460,9 @@ __metadata: linkType: hard "@metamask/slip44@npm:^4.0.0": - version: 4.0.0 - resolution: "@metamask/slip44@npm:4.0.0" - checksum: 10/3e47e8834b0fbdabe1f126fd78665767847ddc1f9ccc8defb23007dd71fcd2e4899c8ca04857491be3630668a3765bad1e40fdfca9a61ef33236d8d08e51535e + version: 4.1.0 + resolution: "@metamask/slip44@npm:4.1.0" + checksum: 10/4265254a1800a24915bd1de15f86f196737132f9af2a084c2efc885decfc5dd87ad8f0687269d90b35e2ec64d3ea4fbff0caa793bcea6e585b1f3a290952b750 languageName: node linkType: hard @@ -6206,9 +6489,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.12.0": - version: 9.12.0 - resolution: "@metamask/snaps-controllers@npm:9.12.0" +"@metamask/snaps-controllers@npm:^9.10.0, @metamask/snaps-controllers@npm:^9.13.0": + version: 9.13.0 + resolution: "@metamask/snaps-controllers@npm:9.13.0" dependencies: "@metamask/approval-controller": "npm:^7.1.1" "@metamask/base-controller": "npm:^7.0.2" @@ -6221,8 +6504,8 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/snaps-registry": "npm:^3.2.2" "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/utils": "npm:^10.0.0" "@xstate/fsm": "npm:^2.0.0" browserify-zlib: "npm:^0.2.0" @@ -6236,30 +6519,30 @@ __metadata: semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^6.9.2 + "@metamask/snaps-execution-environments": ^6.10.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/8d411ff2cfd43e62fe780092e935a1d977379488407b56cca1390edfa9408871cbaf3599f6e6ee999340d46fd3650f225a3270ceec9492c6f2dc4d93538c25ae + checksum: 10/bcf60b61de067f89439cb15acbdf6f808b4bcda8e1cbc9debd693ca2c545c9d38c4e6f380191c4703bd9d28d7dd41e4ce5111664d7b474d5e86e460bcefc3637 languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^6.9.2": - version: 6.9.2 - resolution: "@metamask/snaps-execution-environments@npm:6.9.2" +"@metamask/snaps-execution-environments@npm:^6.10.0": + version: 6.10.0 + resolution: "@metamask/snaps-execution-environments@npm:6.10.0" dependencies: "@metamask/json-rpc-engine": "npm:^10.0.1" "@metamask/object-multiplex": "npm:^2.0.0" "@metamask/post-message-stream": "npm:^8.1.1" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" nanoid: "npm:^3.1.31" readable-stream: "npm:^3.6.2" - checksum: 10/f81dd3728417dc63ed16b102504cdf6c815bffef7b1dad9e7b0e064618b008e1f0fe6d05c225bcafeee09fb4bc473599ee710e1a26a6f3604e965f656fce8e36 + checksum: 10/a881696ec942f268d7485869fcb8c6bc0c278319bbfaf7e5c6099e86278c7f59049595f00ecfc27511d0106b5ad2f7621f734c7b17f088b835e38e638d80db01 languageName: node linkType: hard @@ -6291,16 +6574,16 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.10.0": - version: 6.10.0 - resolution: "@metamask/snaps-sdk@npm:6.10.0" +"@metamask/snaps-sdk@npm:^6.11.0": + version: 6.11.0 + resolution: "@metamask/snaps-sdk@npm:6.11.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" - checksum: 10/02f04536328a64ff1e9e48fb6b109698d6d83f42af5666a9758ccb1e7a1e67c0c2e296ef2fef419dd3d1c8f26bbf30b9f31911a1baa66f044f21cd0ecb7a11a7 + checksum: 10/0f9b507139d1544b1b3d85ff8de81b800d543012d3ee9414c607c23abe9562e0dca48de089ed94be69f5ad981730a0f443371edfe6bc2d5ffb140b28e437bfd2 languageName: node linkType: hard @@ -6335,9 +6618,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.1.1, @metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.5.1": - version: 8.5.1 - resolution: "@metamask/snaps-utils@npm:8.5.1" +"@metamask/snaps-utils@npm:^8.1.1, @metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": + version: 8.6.0 + resolution: "@metamask/snaps-utils@npm:8.6.0" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" @@ -6347,7 +6630,7 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/slip44": "npm:^4.0.0" "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-sdk": "npm:^6.10.0" + "@metamask/snaps-sdk": "npm:^6.11.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" "@noble/hashes": "npm:^1.3.1" @@ -6362,7 +6645,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.1.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/38c8098c7dfa82bf907d31f77e2d5ffe6a82dca9bf5d633d10ac024f153d94b8124d7ddfb2e3e35befb7af619ebff1900e81476f889011eebdd80b5e12328c30 + checksum: 10/c0f538f3f95e1875f6557b6ecc32f981bc4688d581af8cdc62c6c3ab8951c138286cd0b2d1cd82f769df24fcec10f71dcda67ae9a47edcff9ff73d52672df191 languageName: node linkType: hard @@ -6458,9 +6741,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^38.3.0": - version: 38.3.0 - resolution: "@metamask/transaction-controller@npm:38.3.0" +"@metamask/transaction-controller@npm:^39.1.0": + version: 39.1.0 + resolution: "@metamask/transaction-controller@npm:39.1.0" dependencies: "@ethereumjs/common": "npm:^3.2.0" "@ethereumjs/tx": "npm:^4.2.0" @@ -6469,7 +6752,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/base-controller": "npm:^7.0.2" - "@metamask/controller-utils": "npm:^11.4.2" + "@metamask/controller-utils": "npm:^11.4.3" "@metamask/eth-query": "npm:^4.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/nonce-tracker": "npm:^6.0.0" @@ -6483,11 +6766,11 @@ __metadata: uuid: "npm:^8.3.2" peerDependencies: "@babel/runtime": ^7.23.9 - "@metamask/accounts-controller": ^18.0.0 + "@metamask/accounts-controller": ^19.0.0 "@metamask/approval-controller": ^7.0.0 "@metamask/gas-fee-controller": ^22.0.0 "@metamask/network-controller": ^22.0.0 - checksum: 10/f4e8e3a1a31e3e62b0d1a59bbe15ebfa4dc3e4cf077fb95c1815c00661c60ef4676046c49f57eab9749cd31d3e55ac3fed7bc247e3f5a3d459f2dcb03998633d + checksum: 10/9c18f01167ca70556323190c3b3b8df29d5c1d45846e6d50208b49d27bd3d361ab89f103d5f4a784bbc70cee3e5ef595bab8cf568926c790236d32ace07a1283 languageName: node linkType: hard @@ -13235,14 +13518,25 @@ __metadata: languageName: node linkType: hard +"axios@npm:^0.28.0": + version: 0.28.1 + resolution: "axios@npm:0.28.1" + dependencies: + follow-redirects: "npm:^1.15.0" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10/3eb6799ce716de877c3015ddc2cbb5d5176c914d777c36076e097157792f2bc6d0a491156a9239bf32e8dfe1c138ec008d6bd31f4c5602d8e7b915111c10b635 + languageName: node + linkType: hard + "axios@npm:^1.1.3": - version: 1.7.4 - resolution: "axios@npm:1.7.4" + version: 1.7.7 + resolution: "axios@npm:1.7.7" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/7a1429be1e3d0c2e1b96d4bba4d113efbfabc7c724bed107beb535c782c7bea447ff634886b0c7c43395a264d085450d009eb1154b5f38a8bae49d469fdcbc61 + checksum: 10/7f875ea13b9298cd7b40fd09985209f7a38d38321f1118c701520939de2f113c4ba137832fe8e3f811f99a38e12c8225481011023209a77b0c0641270e20cde1 languageName: node linkType: hard @@ -13452,6 +13746,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^0.4.1": + version: 0.4.2 + resolution: "balanced-match@npm:0.4.2" + checksum: 10/205ebb42ce8529fa8e043a808b41bfb9818d5f98a8eb76a1cd5483f8a98dd0baefc8a9d940f36b591b1316a04f56b35c32b60ac9b1f848e41e4698672cec6c1e + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -15947,25 +16248,14 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^5.0.1": - version: 5.1.0 - resolution: "cross-spawn@npm:5.1.0" - dependencies: - lru-cache: "npm:^4.0.1" - shebang-command: "npm:^1.2.0" - which: "npm:^1.2.9" - checksum: 10/726939c9954fc70c20e538923feaaa33bebc253247d13021737c3c7f68cdc3e0a57f720c0fe75057c0387995349f3f12e20e9bfdbf12274db28019c7ea4ec166 - languageName: node - linkType: hard - -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": - version: 7.0.3 - resolution: "cross-spawn@npm:7.0.3" +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" dependencies: path-key: "npm:^3.1.0" shebang-command: "npm:^2.0.0" which: "npm:^2.0.1" - checksum: 10/e1a13869d2f57d974de0d9ef7acbf69dc6937db20b918525a01dacb5032129bd552d290d886d981e99f1b624cb03657084cc87bd40f115c07ecf376821c729ce + checksum: 10/0d52657d7ae36eb130999dffff1168ec348687b48dd38e2ff59992ed916c88d328cf1d07ff4a4a10bc78de5e1c23f04b306d569e42f7a2293915c081e4dfee86 languageName: node linkType: hard @@ -15995,7 +16285,7 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:^4.2.0": +"crypto-js@npm:4.2.0, crypto-js@npm:^4.2.0": version: 4.2.0 resolution: "crypto-js@npm:4.2.0" checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 @@ -17480,6 +17770,15 @@ __metadata: languageName: node linkType: hard +"eip55@npm:^2.1.1": + version: 2.1.1 + resolution: "eip55@npm:2.1.1" + dependencies: + keccak: "npm:^3.0.3" + checksum: 10/512d319e4f91ab0c33b514f371206956521dcdcdd23e8eb4d6f9c21e3be9f72287c0b82feb854d3a1eec91805804d13c31e7a1a7dafd37f69eb9994a9c6c8f32 + languageName: node + linkType: hard + "ejs@npm:^3.1.5, ejs@npm:^3.1.8": version: 3.1.9 resolution: "ejs@npm:3.1.9" @@ -18871,7 +19170,7 @@ __metadata: languageName: node linkType: hard -"ethers@npm:^5.7.0, ethers@npm:^5.7.2": +"ethers@npm:5.7.2, ethers@npm:^5.7.0, ethers@npm:^5.7.2": version: 5.7.2 resolution: "ethers@npm:5.7.2" dependencies: @@ -19988,13 +20287,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.6": - version: 1.15.6 - resolution: "follow-redirects@npm:1.15.6" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0, follow-redirects@npm:^1.14.9, follow-redirects@npm:^1.15.0, follow-redirects@npm:^1.15.6": + version: 1.15.9 + resolution: "follow-redirects@npm:1.15.9" peerDependenciesMeta: debug: optional: true - checksum: 10/70c7612c4cab18e546e36b991bbf8009a1a41cf85354afe04b113d1117569abf760269409cb3eb842d9f7b03d62826687086b081c566ea7b1e6613cf29030bf7 + checksum: 10/e3ab42d1097e90d28b913903841e6779eb969b62a64706a3eb983e894a5db000fbd89296f45f08885a0e54cd558ef62e81be1165da9be25a6c44920da10f424c languageName: node linkType: hard @@ -21410,6 +21709,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "has-flag@npm:1.0.0" + checksum: 10/ce3f8ae978e70f16e4bbe17d3f0f6d6c0a3dd3b62a23f97c91d0fda9ed8e305e13baf95cc5bee4463b9f25ac9f5255de113165c5fb285e01b8065b2ac079b301 + languageName: node + linkType: hard + "has-flag@npm:^3.0.0": version: 3.0.0 resolution: "has-flag@npm:3.0.0" @@ -24278,6 +24584,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^2.1.9": + version: 2.6.4 + resolution: "js-base64@npm:2.6.4" + checksum: 10/c1a740a34fbb0ad0a528c2ab8749d7f873b1856a0638826306fcd98502e3c8c833334dff233085407e3201be543e5e71bf9692da7891ca680d9b03d027247a6a + languageName: node + linkType: hard + "js-base64@npm:^3.6.0": version: 3.6.1 resolution: "js-base64@npm:3.6.1" @@ -24930,7 +25243,7 @@ __metadata: languageName: node linkType: hard -"keccak@npm:^3.0.0": +"keccak@npm:^3.0.0, keccak@npm:^3.0.3": version: 3.0.4 resolution: "keccak@npm:3.0.4" dependencies: @@ -25807,16 +26120,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^4.0.1": - version: 4.1.1 - resolution: "lru-cache@npm:4.1.1" - dependencies: - pseudomap: "npm:^1.0.2" - yallist: "npm:^2.1.2" - checksum: 10/a412db13e89abe202c2314e633bd8580be2a668ba2036c34da376ac66163aa9fba4727ca66ff7907ad68fb574511f0bc6275c0598fdaeeab92e1125f5397d0e4 - languageName: node - linkType: hard - "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -26516,7 +26819,7 @@ __metadata: "@metamask/announcement-controller": "npm:^7.0.0" "@metamask/api-specs": "npm:^0.9.3" "@metamask/approval-controller": "npm:^7.0.0" - "@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A42.0.0#~/.yarn/patches/@metamask-assets-controllers-npm-42.0.0-57b3d695bb.patch" + "@metamask/assets-controllers": "patch:@metamask/assets-controllers@patch%3A@metamask/assets-controllers@npm%253A44.0.0%23~/.yarn/patches/@metamask-assets-controllers-npm-44.0.0-c223d56176.patch%3A%3Aversion=44.0.0&hash=5a94c2#~/.yarn/patches/@metamask-assets-controllers-patch-9e00573eb4.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2" @@ -26535,7 +26838,7 @@ __metadata: "@metamask/eslint-plugin-design-tokens": "npm:^1.1.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/eth-json-rpc-middleware": "patch:@metamask/eth-json-rpc-middleware@npm%3A14.0.1#~/.yarn/patches/@metamask-eth-json-rpc-middleware-npm-14.0.1-b6c2ccbe8c.patch" - "@metamask/eth-ledger-bridge-keyring": "npm:^3.0.1" + "@metamask/eth-ledger-bridge-keyring": "npm:^5.0.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/eth-sig-util": "npm:^7.0.1" "@metamask/eth-snap-keyring": "npm:^4.4.0" @@ -26572,7 +26875,7 @@ __metadata: "@metamask/ppom-validator": "npm:0.35.1" "@metamask/preferences-controller": "npm:^13.0.2" "@metamask/preinstalled-example-snap": "npm:^0.2.0" - "@metamask/profile-sync-controller": "npm:^0.9.7" + "@metamask/profile-sync-controller": "npm:^1.0.2" "@metamask/providers": "npm:^14.0.2" "@metamask/queued-request-controller": "npm:^7.0.1" "@metamask/rate-limit-controller": "npm:^6.0.0" @@ -26580,17 +26883,17 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.1.1" "@metamask/scure-bip39": "npm:^2.0.3" "@metamask/selected-network-controller": "npm:^18.0.2" - "@metamask/signature-controller": "npm:^21.0.0" + "@metamask/signature-controller": "npm:^21.1.0" "@metamask/smart-transactions-controller": "npm:^13.0.0" - "@metamask/snaps-controllers": "npm:^9.12.0" - "@metamask/snaps-execution-environments": "npm:^6.9.2" + "@metamask/snaps-controllers": "npm:^9.13.0" + "@metamask/snaps-execution-environments": "npm:^6.10.0" "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.1" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/solana-wallet-snap": "npm:^0.1.9" "@metamask/test-bundler": "npm:^1.0.0" "@metamask/test-dapp": "npm:8.13.0" - "@metamask/transaction-controller": "npm:^38.3.0" + "@metamask/transaction-controller": "npm:^39.1.0" "@metamask/user-operation-controller": "npm:^13.0.0" "@metamask/utils": "npm:^10.0.1" "@ngraveio/bc-ur": "npm:^1.1.12" @@ -26703,7 +27006,7 @@ __metadata: copy-to-clipboard: "npm:^3.3.3" copy-webpack-plugin: "npm:^12.0.2" core-js-pure: "npm:^3.38.0" - cross-spawn: "npm:^7.0.3" + cross-spawn: "npm:^7.0.5" crypto-browserify: "npm:^3.12.0" css-loader: "npm:^6.10.0" css-to-xpath: "npm:^0.1.0" @@ -26798,6 +27101,7 @@ __metadata: path-browserify: "npm:^1.0.1" pify: "npm:^5.0.0" postcss: "npm:^8.4.32" + postcss-discard-font-face: "npm:^3.0.0" postcss-loader: "npm:^8.1.1" postcss-rtlcss: "npm:^4.0.9" prettier: "npm:^2.7.1" @@ -26841,7 +27145,6 @@ __metadata: remove-trailing-slash: "npm:^0.1.1" reselect: "npm:^3.0.1" resolve-url-loader: "npm:^3.1.5" - rimraf: "npm:^5.0.5" sass-embedded: "npm:^1.71.0" sass-loader: "npm:^14.1.1" schema-utils: "npm:^4.2.0" @@ -29797,6 +30100,16 @@ __metadata: languageName: node linkType: hard +"postcss-discard-font-face@npm:^3.0.0": + version: 3.0.0 + resolution: "postcss-discard-font-face@npm:3.0.0" + dependencies: + balanced-match: "npm:^0.4.1" + postcss: "npm:^5.0.21" + checksum: 10/968632c4426bb04816787575ad768ad0bca572573366aa7ef2f91069ab5224319bea3517b14d7ffb501f6787bc1d8d4a265f40027d4cc593cfcd7824bf834c34 + languageName: node + linkType: hard + "postcss-html@npm:^0.36.0": version: 0.36.0 resolution: "postcss-html@npm:0.36.0" @@ -30064,6 +30377,18 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^5.0.21": + version: 5.2.18 + resolution: "postcss@npm:5.2.18" + dependencies: + chalk: "npm:^1.1.3" + js-base64: "npm:^2.1.9" + source-map: "npm:^0.5.6" + supports-color: "npm:^3.2.3" + checksum: 10/ad157696a258c37e8cdebd28c575bba8e028276b030621d7ac191f855b61d56de9fe751f405ca088693806971426a7321f0b0201e0e828e99b1960ac8f474215 + languageName: node + linkType: hard + "postcss@npm:^7.0.14, postcss@npm:^7.0.16, postcss@npm:^7.0.2, postcss@npm:^7.0.21, postcss@npm:^7.0.26, postcss@npm:^7.0.32, postcss@npm:^7.0.6, postcss@npm:^7.0.7": version: 7.0.39 resolution: "postcss@npm:7.0.39" @@ -30432,13 +30757,6 @@ __metadata: languageName: node linkType: hard -"pseudomap@npm:^1.0.2": - version: 1.0.2 - resolution: "pseudomap@npm:1.0.2" - checksum: 10/856c0aae0ff2ad60881168334448e898ad7a0e45fe7386d114b150084254c01e200c957cf378378025df4e052c7890c5bd933939b0e0d2ecfcc1dc2f0b2991f5 - languageName: node - linkType: hard - "psl@npm:^1.1.33": version: 1.9.0 resolution: "psl@npm:1.9.0" @@ -33514,15 +33832,6 @@ __metadata: languageName: node linkType: hard -"shebang-command@npm:^1.2.0": - version: 1.2.0 - resolution: "shebang-command@npm:1.2.0" - dependencies: - shebang-regex: "npm:^1.0.0" - checksum: 10/9eed1750301e622961ba5d588af2212505e96770ec376a37ab678f965795e995ade7ed44910f5d3d3cb5e10165a1847f52d3348c64e146b8be922f7707958908 - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -33532,13 +33841,6 @@ __metadata: languageName: node linkType: hard -"shebang-regex@npm:^1.0.0": - version: 1.0.0 - resolution: "shebang-regex@npm:1.0.0" - checksum: 10/404c5a752cd40f94591dfd9346da40a735a05139dac890ffc229afba610854d8799aaa52f87f7e0c94c5007f2c6af55bdcaeb584b56691926c5eaf41dc8f1372 - languageName: node - linkType: hard - "shebang-regex@npm:^3.0.0": version: 3.0.0 resolution: "shebang-regex@npm:3.0.0" @@ -34860,6 +35162,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^3.2.3": + version: 3.2.3 + resolution: "supports-color@npm:3.2.3" + dependencies: + has-flag: "npm:^1.0.0" + checksum: 10/476a70d263a1f7ac11c26c10dfc58f0d9439edf198005b95f0e358ea8182d06b492d96320f16a841e4e968c7189044dd8c3f3037bd533480d15c7cc00e17c5d8 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -36741,6 +37052,13 @@ __metadata: languageName: node linkType: hard +"utility-types@npm:^3.10.0": + version: 3.11.0 + resolution: "utility-types@npm:3.11.0" + checksum: 10/a3c51463fc807ed04ccc8b5d0fa6e31f3dcd7a4cbd30ab4bc6d760ce5319dd493d95bf04244693daf316f97e9ab2a37741edfed8748ad38572a595398ad0fdaf + languageName: node + linkType: hard + "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" @@ -37681,7 +37999,7 @@ __metadata: languageName: node linkType: hard -"which@npm:^1.2.12, which@npm:^1.2.14, which@npm:^1.2.9, which@npm:^1.3.1": +"which@npm:^1.2.12, which@npm:^1.2.14, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -38015,13 +38333,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^2.1.2": - version: 2.1.2 - resolution: "yallist@npm:2.1.2" - checksum: 10/75fc7bee4821f52d1c6e6021b91b3e079276f1a9ce0ad58da3c76b79a7e47d6f276d35e206a96ac16c1cf48daee38a8bb3af0b1522a3d11c8ffe18f898828832 - languageName: node - linkType: hard - "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1"