From 8325697771abfd420d149af24148df6506895c3d Mon Sep 17 00:00:00 2001 From: salimtb Date: Fri, 8 Nov 2024 10:50:52 +0100 Subject: [PATCH] feat: multichain token autodetection --- .storybook/test-data.js | 7 + ...ts-controllers-npm-44.1.0-012aa448d8.patch | 71 +++++++ package.json | 2 +- .../app/assets/asset-list/asset-list.tsx | 34 +++- .../detected-token-details.js | 8 +- .../detected-token-selection-popover.js | 76 ++++++-- ...etected-token-selection-popover.stories.js | 5 + .../detected-token-values.js | 24 ++- .../detected-token-values.stories.js | 27 ++- .../app/detected-token/detected-token.js | 178 +++++++++++++++--- .../app/detected-token/detected-token.test.js | 14 ++ .../hide-token-confirmation-modal.js | 30 ++- .../hide-token-confirmation-modal.test.js | 1 + .../detected-token-banner.js | 49 ++++- .../import-nfts-modal/import-nfts-modal.js | 1 + ui/hooks/useTokenFiatAmount.js | 37 +++- ui/selectors/selectors.js | 40 ++++ ui/store/actions.test.js | 4 +- ui/store/actions.ts | 8 +- yarn.lock | 57 +----- 20 files changed, 541 insertions(+), 132 deletions(-) create mode 100644 .yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch diff --git a/.storybook/test-data.js b/.storybook/test-data.js index a36cbf944981..717109b77dac 100644 --- a/.storybook/test-data.js +++ b/.storybook/test-data.js @@ -525,6 +525,13 @@ const state = { decimals: 18, }, ], + tokenBalances: { + '0x64a845a5b02460acf8a3d84503b0d68d028b4bb4': { + '0x1': { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x25e4bc', + }, + }, + }, allDetectedTokens: { '0xaa36a7': { '0x9d0ba4ddac06032527b140912ec808ab9451b788': [ diff --git a/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch b/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch new file mode 100644 index 000000000000..faf388c89741 --- /dev/null +++ b/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch @@ -0,0 +1,71 @@ +diff --git a/dist/assetsUtil.cjs b/dist/assetsUtil.cjs +index 48571b8c1b78e94d88e1837e986b5f8735ac651b..61246f51500c8cab48f18296a73629fb73454caa 100644 +--- a/dist/assetsUtil.cjs ++++ b/dist/assetsUtil.cjs +@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; + }; + 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.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"); +@@ -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; +- const { CID } = await import("multiformats"); ++ const { CID } = _interopRequireWildcard(require("multiformats")); + // We want to ensure that the CID is v1 (https://docs.ipfs.io/concepts/content-addressing/#identifier-formats) + // because most cid v0s appear to be incompatible with IPFS subdomains + return { +diff --git a/dist/token-prices-service/codefi-v2.mjs b/dist/token-prices-service/codefi-v2.mjs +index e7eaad2cfa8b233c4fd42a51f745233a1cc5c387..bf8ec7819f678c2f185d6a85d7e3ea81f055a309 100644 +--- a/dist/token-prices-service/codefi-v2.mjs ++++ b/dist/token-prices-service/codefi-v2.mjs +@@ -12,8 +12,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function ( + var _CodefiTokenPricesServiceV2_tokenPricePolicy; + import { handleFetch } from "@metamask/controller-utils"; + import { hexToNumber } from "@metamask/utils"; +-import $cockatiel from "cockatiel"; +-const { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } = $cockatiel; ++import { circuitBreaker, ConsecutiveBreaker, ExponentialBackoff, handleAll, retry, wrap, CircuitState } from "cockatiel" + /** + * The list of currencies that can be supplied as the `vsCurrency` parameter to + * the `/spot-prices` endpoint, in lowercase form. +diff --git a/dist/TokenDetectionController.cjs b/dist/TokenDetectionController.cjs +index 8fd5efde7a3c24080f8a43f79d10300e8c271245..a3c334ac7dd2e5698e6b54a73491b7145c2a9010 100644 +--- a/dist/TokenDetectionController.cjs ++++ b/dist/TokenDetectionController.cjs +@@ -250,17 +250,20 @@ _TokenDetectionController_intervalId = new WeakMap(), _TokenDetectionController_ + } + }); + this.messagingSystem.subscribe('AccountsController:selectedEvmAccountChange', +- // TODO: Either fix this lint violation or explain why it's necessary to ignore. +- // eslint-disable-next-line @typescript-eslint/no-misused-promises +- async (selectedAccount) => { +- const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; +- if (isSelectedAccountIdChanged) { +- __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); +- await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { +- selectedAddress: selectedAccount.address, +- }); +- } +- }); ++ // TODO: Either fix this lint violation or explain why it's necessary to ignore. ++ // eslint-disable-next-line @typescript-eslint/no-misused-promises ++ async (selectedAccount) => { ++ const { networkConfigurationsByChainId } = this.messagingSystem.call('NetworkController:getState'); ++ const chainIds = Object.keys(networkConfigurationsByChainId); ++ const isSelectedAccountIdChanged = __classPrivateFieldGet(this, _TokenDetectionController_selectedAccountId, "f") !== selectedAccount.id; ++ if (isSelectedAccountIdChanged) { ++ __classPrivateFieldSet(this, _TokenDetectionController_selectedAccountId, selectedAccount.id, "f"); ++ await __classPrivateFieldGet(this, _TokenDetectionController_instances, "m", _TokenDetectionController_restartTokenDetection).call(this, { ++ selectedAddress: selectedAccount.address, ++ chainIds, ++ }); ++ } ++ }); + }, _TokenDetectionController_stopPolling = function _TokenDetectionController_stopPolling() { + if (__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")) { + clearInterval(__classPrivateFieldGet(this, _TokenDetectionController_intervalId, "f")); diff --git a/package.json b/package.json index 7117462c957f..39c0afd5983d 100644 --- a/package.json +++ b/package.json @@ -293,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@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/assets-controllers": "patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch", "@metamask/base-controller": "^7.0.0", "@metamask/bitcoin-wallet-snap": "^0.8.2", "@metamask/browser-passworder": "^4.3.0", diff --git a/ui/components/app/assets/asset-list/asset-list.tsx b/ui/components/app/assets/asset-list/asset-list.tsx index 9ed6b718cbd8..9ae4d49f557a 100644 --- a/ui/components/app/assets/asset-list/asset-list.tsx +++ b/ui/components/app/assets/asset-list/asset-list.tsx @@ -4,8 +4,10 @@ import TokenList from '../token-list'; import { PRIMARY } from '../../../../helpers/constants/common'; import { useUserPreferencedCurrency } from '../../../../hooks/useUserPreferencedCurrency'; import { + getAllDetectedTokensForSelectedAddress, getDetectedTokensInCurrentNetwork, getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, + getPreferences, getSelectedAccount, } from '../../../../selectors'; import { @@ -75,6 +77,8 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { const isTokenDetectionInactiveOnNonMainnetSupportedNetwork = useSelector( getIstokenDetectionInactiveOnNonMainnetSupportedNetwork, ); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; const [showFundingMethodModal, setShowFundingMethodModal] = useState(false); const [showReceiveModal, setShowReceiveModal] = useState(false); @@ -98,16 +102,30 @@ const AssetList = ({ onClickAsset, showTokensLinks }: AssetListProps) => { // for EVM assets const shouldShowTokensLinks = showTokensLinks ?? isEvm; + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, + ); + + const totalTokens = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? (Object.values(detectedTokensMultichain).reduce( + // @ts-expect-error TS18046: 'tokenArray' is of type 'unknown' + (count, tokenArray) => count + tokenArray.length, + 0, + ) as number) + : detectedTokens.length; + return ( <> - {detectedTokens.length > 0 && - !isTokenDetectionInactiveOnNonMainnetSupportedNetwork && ( - setShowDetectedTokens(true)} - margin={4} - /> - )} + {totalTokens && + totalTokens > 0 && + !isTokenDetectionInactiveOnNonMainnetSupportedNetwork ? ( + setShowDetectedTokens(true)} + margin={4} + /> + ) : null} } diff --git a/ui/components/app/detected-token/detected-token-details/detected-token-details.js b/ui/components/app/detected-token/detected-token-details/detected-token-details.js index fe8f12618305..abdc1103e10d 100644 --- a/ui/components/app/detected-token/detected-token-details/detected-token-details.js +++ b/ui/components/app/detected-token/detected-token-details/detected-token-details.js @@ -14,20 +14,20 @@ import DetectedTokenAddress from '../detected-token-address/detected-token-addre import DetectedTokenAggregators from '../detected-token-aggregators/detected-token-aggregators'; import { Display } from '../../../../helpers/constants/design-system'; import { - getCurrentNetwork, getTestNetworkBackgroundColor, getTokenList, } from '../../../../selectors'; +import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP } from '../../../../../shared/constants/network'; const DetectedTokenDetails = ({ token, handleTokenSelection, tokensListDetected, + chainId, }) => { const tokenList = useSelector(getTokenList); const tokenData = tokenList[token.address?.toLowerCase()]; const testNetworkBackgroundColor = useSelector(getTestNetworkBackgroundColor); - const currentNetwork = useSelector(getCurrentNetwork); return ( } @@ -84,6 +83,7 @@ DetectedTokenDetails.propTypes = { }), handleTokenSelection: PropTypes.func.isRequired, tokensListDetected: PropTypes.object, + chainId: PropTypes.string, }; export default DetectedTokenDetails; 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 0229173050d8..8dfe00452eec 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 @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; @@ -10,8 +10,11 @@ import { MetaMetricsTokenEventSource, } from '../../../../../shared/constants/metametrics'; import { + getAllDetectedTokensForSelectedAddress, getCurrentChainId, + getCurrentNetwork, getDetectedTokensInCurrentNetwork, + getPreferences, } from '../../../../selectors'; import Popover from '../../../ui/popover'; @@ -34,10 +37,30 @@ const DetectedTokenSelectionPopover = ({ const chainId = useSelector(getCurrentChainId); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; + + const currentNetwork = useSelector(getCurrentNetwork); + + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, + ); + + const totalTokens = useMemo(() => { + return process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).reduce( + (count, tokenArray) => count + tokenArray.length, + 0, + ) + : detectedTokens.length; + }, [detectedTokensMultichain, detectedTokens, allNetworksFilterShown]); + const { selected: selectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); const onClose = () => { + const chainIds = Object.keys(detectedTokensMultichain); + setShowDetectedTokens(false); const eventTokensDetails = detectedTokens.map( ({ address, symbol }) => `${symbol} - ${address}`, @@ -47,8 +70,10 @@ const DetectedTokenSelectionPopover = ({ category: MetaMetricsEventCategory.Wallet, properties: { source_connection_method: MetaMetricsTokenEventSource.Detected, - chain_id: chainId, tokens: eventTokensDetails, + ...(process.env.PORTFOLIO_VIEW + ? { chain_ids: chainIds } + : { chain_id: chainId }), }, }); }; @@ -81,25 +106,44 @@ const DetectedTokenSelectionPopover = ({ - - {detectedTokens.map((token, index) => { - return ( - - ); - })} - + {process.env.PORTFOLIO_VIEW && !allNetworksFilterShown ? ( + + {Object.entries(detectedTokensMultichain).map( + ([networkId, tokens]) => { + return tokens.map((token, index) => ( + + )); + }, + )} + + ) : ( + + {detectedTokens.map((token, index) => { + return ( + + ); + })} + + )} ); }; diff --git a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js index 525e88fc2785..5d7def0f28e9 100644 --- a/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js +++ b/ui/components/app/detected-token/detected-token-selection-popover/detected-token-selection-popover.stories.js @@ -11,6 +11,11 @@ const store = configureStore({ ...testData, metamask: { ...testData.metamask, + currencyRates: { + SepoliaETH: { + conversionRate: 3910.28, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA }), }, }); diff --git a/ui/components/app/detected-token/detected-token-values/detected-token-values.js b/ui/components/app/detected-token/detected-token-values/detected-token-values.js index 667c356b0e1f..07c70edcf196 100644 --- a/ui/components/app/detected-token/detected-token-values/detected-token-values.js +++ b/ui/components/app/detected-token/detected-token-values/detected-token-values.js @@ -7,10 +7,14 @@ import { TextColor, TextVariant, } from '../../../../helpers/constants/design-system'; -import { useTokenTracker } from '../../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../../hooks/useTokenFiatAmount'; -import { getUseCurrencyRateCheck } from '../../../../selectors'; +import { + getCurrentChainId, + getSelectedAddress, + getUseCurrencyRateCheck, +} from '../../../../selectors'; import { Box, Checkbox, Text } from '../../../component-library'; +import { useTokenTracker } from '../../../../hooks/useTokenBalances'; const DetectedTokenValues = ({ token, @@ -21,12 +25,25 @@ const DetectedTokenValues = ({ return tokensListDetected[token.address]?.selected; }); - const { tokensWithBalances } = useTokenTracker({ tokens: [token] }); + const selectedAddress = useSelector(getSelectedAddress); + const currentChainId = useSelector(getCurrentChainId); + const chainId = token.chainId ?? currentChainId; + + const { tokensWithBalances } = useTokenTracker({ + chainId, + tokens: [token], + address: selectedAddress, + hideZeroBalanceTokens: false, + }); + const balanceString = tokensWithBalances[0]?.string; const formattedFiatBalance = useTokenFiatAmount( token.address, balanceString, token.symbol, + {}, + false, + chainId, ); const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck); @@ -73,6 +90,7 @@ DetectedTokenValues.propTypes = { symbol: PropTypes.string, iconUrl: PropTypes.string, aggregators: PropTypes.array, + chainId: PropTypes.string, }), handleTokenSelection: PropTypes.func.isRequired, tokensListDetected: PropTypes.object, diff --git a/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js b/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js index cf5039a7c2a6..32402631b7bf 100644 --- a/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js +++ b/ui/components/app/detected-token/detected-token-values/detected-token-values.stories.js @@ -1,14 +1,16 @@ import React from 'react'; - +import { Provider } from 'react-redux'; +import testData from '../../../../../.storybook/test-data'; +import configureStore from '../../../../store/store'; import DetectedTokenValues from './detected-token-values'; export default { title: 'Components/App/DetectedToken/DetectedTokenValues', - + component: DetectedTokenValues, argTypes: { token: { control: 'object' }, - handleTokenSelection: { control: 'func' }, - tokensListDetected: { control: 'array' }, + handleTokenSelection: { action: 'handleTokenSelection' }, // Action for interactions + tokensListDetected: { control: 'object' }, }, args: { token: { @@ -73,10 +75,21 @@ export default { }, }; -const Template = (args) => { - return ; +// Mock store data +const customData = { + ...testData, + metamask: { + ...testData.metamask, + }, }; -export const DefaultStory = Template.bind({}); +const customStore = configureStore(customData); + +const Template = (args) => ( + + + +); +export const DefaultStory = Template.bind({}); DefaultStory.storyName = 'Default'; diff --git a/ui/components/app/detected-token/detected-token.js b/ui/components/app/detected-token/detected-token.js index 3d9038cc52b9..e27b6111764e 100644 --- a/ui/components/app/detected-token/detected-token.js +++ b/ui/components/app/detected-token/detected-token.js @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector, useDispatch } from 'react-redux'; import { chain } from 'lodash'; @@ -9,8 +9,11 @@ import { setNewTokensImported, } from '../../../store/actions'; import { + getAllDetectedTokensForSelectedAddress, getCurrentChainId, getDetectedTokensInCurrentNetwork, + getNetworkConfigurationsByChainId, + getPreferences, getSelectedNetworkClientId, } from '../../../selectors'; import { MetaMetricsContext } from '../../../contexts/metametrics'; @@ -38,8 +41,8 @@ const sortingBasedOnTokenSelection = (tokensDetected) => { // ditch the 'selected' property and get just the tokens' .mapValues((group) => group.map(({ token }) => { - const { address, symbol, decimals, aggregators } = token; - return { address, symbol, decimals, aggregators }; + const { address, symbol, decimals, aggregators, chainId } = token; + return { address, symbol, decimals, aggregators, chainId }; }), ) // Exit the chain and get the underlying value, an object. @@ -51,16 +54,62 @@ const DetectedToken = ({ setShowDetectedTokens }) => { const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); - const chainId = useSelector(getCurrentChainId); const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); const networkClientId = useSelector(getSelectedNetworkClientId); - - const [tokensListDetected, setTokensListDetected] = useState(() => - detectedTokens.reduce((tokenObj, token) => { - tokenObj[token.address] = { token, selected: true }; - return tokenObj; - }, {}), + const detectedTokensMultichain = useSelector( + getAllDetectedTokensForSelectedAddress, ); + const currentChainId = useSelector(getCurrentChainId); + const { tokenNetworkFilter } = useSelector(getPreferences); + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; + + const configuration = useSelector(getNetworkConfigurationsByChainId); + + const totalDetectedTokens = useMemo(() => { + return process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).flat().length + : detectedTokens.length; + }, [detectedTokens, detectedTokensMultichain, allNetworksFilterShown]); + + const [tokensListDetected, setTokensListDetected] = useState({}); + + useEffect(() => { + const newTokensList = () => { + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + return Object.entries(detectedTokensMultichain).reduce( + (acc, [chainId, tokens]) => { + if (Array.isArray(tokens)) { + tokens.forEach((token) => { + acc[token.address] = { + token: { ...token, chainId }, + selected: tokensListDetected[token.address]?.selected ?? true, + }; + }); + } + return acc; + }, + {}, + ); + } + + return detectedTokens.reduce((tokenObj, token) => { + tokenObj[token.address] = { + token, + selected: tokensListDetected[token.address]?.selected ?? true, + chainId: currentChainId, + }; + return tokenObj; + }, {}); + }; + + setTokensListDetected(newTokensList()); + }, [ + allNetworksFilterShown, + detectedTokensMultichain, + detectedTokens, + currentChainId, + ]); + const [showDetectedTokenIgnoredPopover, setShowDetectedTokenIgnoredPopover] = useState(false); const [partiallyIgnoreDetectedTokens, setPartiallyIgnoreDetectedTokens] = @@ -79,22 +128,53 @@ const DetectedToken = ({ setShowDetectedTokens }) => { token_standard: TokenStandard.ERC20, asset_type: AssetType.token, token_added_type: 'detected', - chain_id: chainId, + chain_id: importedToken.chainId, }, }); }); - await dispatch(addImportedTokens(selectedTokens, networkClientId)); - const tokenSymbols = selectedTokens.map(({ symbol }) => symbol); - dispatch(setNewTokensImported(tokenSymbols.join(', '))); + + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + const tokensByChainId = selectedTokens.reduce((acc, token) => { + const { chainId } = token; + + if (!acc[chainId]) { + acc[chainId] = { tokens: [] }; + } + + acc[chainId].tokens.push(token); + + return acc; + }, {}); + + const importPromises = Object.entries(tokensByChainId).map( + async ([networkId, { tokens }]) => { + const chainConfig = configuration[networkId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch(addImportedTokens(tokens, networkInstanceId)); + const tokenSymbols = tokens.map(({ symbol }) => symbol); + dispatch(setNewTokensImported(tokenSymbols.join(', '))); + }, + ); + + await Promise.all(importPromises); + } else { + await dispatch(addImportedTokens(selectedTokens, networkClientId)); + const tokenSymbols = selectedTokens.map(({ symbol }) => symbol); + dispatch(setNewTokensImported(tokenSymbols.join(', '))); + } }; const handleClearTokensSelection = async () => { const { selected: selectedTokens = [], deselected: deSelectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); - if (deSelectedTokens.length < detectedTokens.length) { + if (deSelectedTokens.length < totalDetectedTokens) { await importSelectedTokens(selectedTokens); } + const tokensDetailsList = deSelectedTokens.map( ({ symbol, address }) => `${symbol} - ${address}`, ); @@ -108,17 +188,53 @@ const DetectedToken = ({ setShowDetectedTokens }) => { asset_type: AssetType.token, }, }); - const deSelectedTokensAddresses = deSelectedTokens.map( - ({ address }) => address, - ); - await dispatch( - ignoreTokens({ - tokensToIgnore: deSelectedTokensAddresses, - dontShowLoadingIndicator: true, - }), - ); - setShowDetectedTokens(false); - setPartiallyIgnoreDetectedTokens(false); + + if (process.env.PORTFOLIO_VIEW && !allNetworksFilterShown) { + // group deselected tokens by chainId + const groupedByChainId = deSelectedTokens.reduce((acc, token) => { + const { chainId } = token; + if (!acc[chainId]) { + acc[chainId] = []; + } + acc[chainId].push(token); + return acc; + }, {}); + + const promises = Object.entries(groupedByChainId).map( + async ([chainId, tokens]) => { + const chainConfig = configuration[chainId]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; + + await dispatch( + ignoreTokens({ + tokensToIgnore: tokens, + dontShowLoadingIndicator: true, + networkClientId: networkInstanceId, + }), + ); + }, + ); + + await Promise.all(promises); + setShowDetectedTokens(false); + setPartiallyIgnoreDetectedTokens(false); + } else { + const deSelectedTokensAddresses = deSelectedTokens.map( + ({ address }) => address, + ); + + await dispatch( + ignoreTokens({ + tokensToIgnore: deSelectedTokensAddresses, + dontShowLoadingIndicator: true, + }), + ); + + setShowDetectedTokens(false); + setPartiallyIgnoreDetectedTokens(false); + } }; const handleTokenSelection = (token) => { @@ -135,7 +251,7 @@ const DetectedToken = ({ setShowDetectedTokens }) => { const { selected: selectedTokens = [] } = sortingBasedOnTokenSelection(tokensListDetected); - if (selectedTokens.length < detectedTokens.length) { + if (selectedTokens.length < totalDetectedTokens) { setShowDetectedTokenIgnoredPopover(true); setPartiallyIgnoreDetectedTokens(true); } else { @@ -169,9 +285,13 @@ const DetectedToken = ({ setShowDetectedTokens }) => { partiallyIgnoreDetectedTokens={partiallyIgnoreDetectedTokens} /> )} - {detectedTokens.length > 0 && ( + {totalDetectedTokens > 0 && ( { ...testData, metamask: { ...testData.metamask, + currencyRates: { + SepoliaETH: { + conversionDate: 1620710825.03, + conversionRate: 3910.28, + usdConversionRate: 3910.28, + }, + }, ...mockNetworkState({ chainId: CHAIN_IDS.SEPOLIA }), + tokenBalances: { + '0x514910771af9ca656af840dff83e8264ecf986ca': { + [CHAIN_IDS.SEPOLIA]: { + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': '0x25e4bc', + }, + }, + }, }, }); const props = { diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js index f9f15d211806..ab4d803d4f09 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.js @@ -9,23 +9,28 @@ import { MetaMetricsEventCategory, MetaMetricsEventName, } from '../../../../../shared/constants/metametrics'; -import { getCurrentChainId } from '../../../../selectors'; +import { + getCurrentChainId, + getNetworkConfigurationsByChainId, +} from '../../../../selectors'; function mapStateToProps(state) { return { chainId: getCurrentChainId(state), token: state.appState.modal.modalState.props.token, history: state.appState.modal.modalState.props.history, + networkConfigurationsByChainId: getNetworkConfigurationsByChainId(state), }; } function mapDispatchToProps(dispatch) { return { hideModal: () => dispatch(actions.hideModal()), - hideToken: (address) => { + hideToken: (address, networkClientId) => { dispatch( actions.ignoreTokens({ tokensToIgnore: address, + networkClientId, }), ).then(() => { dispatch(actions.hideModal()); @@ -44,10 +49,12 @@ class HideTokenConfirmationModal extends Component { hideToken: PropTypes.func.isRequired, hideModal: PropTypes.func.isRequired, chainId: PropTypes.string.isRequired, + networkConfigurationsByChainId: PropTypes.object.isRequired, token: PropTypes.shape({ symbol: PropTypes.string, address: PropTypes.string, image: PropTypes.string, + chainId: PropTypes.string, }), history: PropTypes.object, }; @@ -55,8 +62,21 @@ class HideTokenConfirmationModal extends Component { state = {}; render() { - const { chainId, token, hideToken, hideModal, history } = this.props; - const { symbol, address, image } = token; + const { + chainId, + token, + hideToken, + hideModal, + history, + networkConfigurationsByChainId, + } = this.props; + const { symbol, address, image, chainId: tokenChainId } = token; + const chainIdToUse = tokenChainId || chainId; + + const chainConfig = networkConfigurationsByChainId[chainIdToUse]; + const { defaultRpcEndpointIndex } = chainConfig; + const { networkClientId: networkInstanceId } = + chainConfig.rpcEndpoints[defaultRpcEndpointIndex]; return (
@@ -96,7 +116,7 @@ class HideTokenConfirmationModal extends Component { token_symbol: symbol, }, }); - hideToken(address); + hideToken(address, networkInstanceId); history.push(DEFAULT_ROUTE); }} > diff --git a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js index 6a53c80a378d..982a6a272943 100644 --- a/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js +++ b/ui/components/app/modals/hide-token-confirmation-modal/hide-token-confirmation-modal.test.js @@ -79,6 +79,7 @@ describe('Hide Token Confirmation Modal', () => { expect(mockHideModal).toHaveBeenCalled(); expect(actions.ignoreTokens).toHaveBeenCalledWith({ tokensToIgnore: tokenState.address, + networkClientId: 'goerli', }); }); }); diff --git a/ui/components/multichain/detected-token-banner/detected-token-banner.js b/ui/components/multichain/detected-token-banner/detected-token-banner.js index 8851614ec948..162650f8a772 100644 --- a/ui/components/multichain/detected-token-banner/detected-token-banner.js +++ b/ui/components/multichain/detected-token-banner/detected-token-banner.js @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -7,6 +7,9 @@ import { useI18nContext } from '../../../hooks/useI18nContext'; import { getCurrentChainId, getDetectedTokensInCurrentNetwork, + getPreferences, + getSelectedInternalAccount, + getAllDetectedTokens, } from '../../../selectors'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { @@ -23,14 +26,48 @@ export const DetectedTokensBanner = ({ }) => { const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); + const { tokenNetworkFilter } = useSelector(getPreferences); + + const allNetworksFilterShown = Object.keys(tokenNetworkFilter ?? {}).length; const detectedTokens = useSelector(getDetectedTokensInCurrentNetwork); - const detectedTokensDetails = detectedTokens.map( - ({ address, symbol }) => `${symbol} - ${address}`, - ); + const { address: selectedAddress } = useSelector(getSelectedInternalAccount); + const allDetectedTokens = useSelector(getAllDetectedTokens); + + const { detectedTokensMultichain } = useMemo(() => { + const detectedTokensAllChains = Object.entries( + allDetectedTokens || {}, + ).reduce((acc, [chainId, chainTokens]) => { + const tokensForAddress = chainTokens[selectedAddress]; + if (tokensForAddress) { + acc[chainId] = tokensForAddress.map((token) => ({ + ...token, + chainId, + })); + } + return acc; + }, {}); + + return { detectedTokensMultichain: detectedTokensAllChains }; + }, [selectedAddress, allDetectedTokens]); const chainId = useSelector(getCurrentChainId); + const detectedTokensDetails = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain) + .flat() + .map(({ address, symbol }) => `${symbol} - ${address}`) + : detectedTokens.map(({ address, symbol }) => `${symbol} - ${address}`); + + const totalTokens = + process.env.PORTFOLIO_VIEW && !allNetworksFilterShown + ? Object.values(detectedTokensMultichain).reduce( + (count, tokenArray) => count + tokenArray.length, + 0, + ) + : detectedTokens.length; + const handleOnClick = () => { actionButtonOnClick(); trackEvent({ @@ -51,9 +88,9 @@ export const DetectedTokensBanner = ({ data-testid="detected-token-banner" {...props} > - {detectedTokens.length === 1 + {totalTokens === 1 ? t('numberOfNewTokensDetectedSingular') - : t('numberOfNewTokensDetectedPlural', [detectedTokens.length])} + : t('numberOfNewTokensDetectedPlural', [totalTokens])} ); }; diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js index 8b85e5cf1a4b..549f0ab5c70d 100644 --- a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js @@ -76,6 +76,7 @@ export const ImportNftsModal = ({ onClose }) => { const [disabled, setDisabled] = useState(true); const [nftAddFailed, setNftAddFailed] = useState(false); const trackEvent = useContext(MetaMetricsContext); + const [nftAddressValidationError, setNftAddressValidationError] = useState(null); const [duplicateTokenIdError, setDuplicateTokenIdError] = useState(null); diff --git a/ui/hooks/useTokenFiatAmount.js b/ui/hooks/useTokenFiatAmount.js index dfa4144b90e3..5abbbc608de0 100644 --- a/ui/hooks/useTokenFiatAmount.js +++ b/ui/hooks/useTokenFiatAmount.js @@ -5,6 +5,9 @@ import { getCurrentCurrency, getShouldShowFiat, getConfirmationExchangeRates, + getMarketData, + getCurrencyRates, + getNetworkConfigurationsByChainId, } from '../selectors'; import { getTokenFiatAmount } from '../helpers/utils/token-util'; import { getConversionRate } from '../ducks/metamask/metamask'; @@ -22,6 +25,7 @@ import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; * @param {boolean} [overrides.showFiat] - If truthy, ensures the fiat value is shown even if the showFiat value from state is falsey * @param {boolean} hideCurrencySymbol - Indicates whether the returned formatted amount should include the trailing currency symbol * @returns {string} The formatted token amount in the user's chosen fiat currency + * @param {string} [chainId] - The chain id */ export function useTokenFiatAmount( tokenAddress, @@ -29,17 +33,44 @@ export function useTokenFiatAmount( tokenSymbol, overrides = {}, hideCurrencySymbol, + chainId = null, ) { + const allMarketData = useSelector(getMarketData); + const contractExchangeRates = useSelector( getTokenExchangeRates, shallowEqual, ); + + const contractMarketData = chainId + ? Object.entries(allMarketData[chainId]).reduce( + (acc, [address, marketData]) => { + acc[address] = marketData?.price ?? null; + return acc; + }, + {}, + ) + : null; + + const tokenMarketData = chainId ? contractMarketData : contractExchangeRates; + const confirmationExchangeRates = useSelector(getConfirmationExchangeRates); const mergedRates = { - ...contractExchangeRates, + ...tokenMarketData, ...confirmationExchangeRates, }; + + const currencyRates = useSelector(getCurrencyRates); const conversionRate = useSelector(getConversionRate); + const networkConfigurationsByChainId = useSelector( + getNetworkConfigurationsByChainId, + ); + + const tokenConversionRate = chainId + ? currencyRates[networkConfigurationsByChainId[chainId].nativeCurrency] + .conversionRate + : conversionRate; + const currentCurrency = useSelector(getCurrentCurrency); const userPrefersShownFiat = useSelector(getShouldShowFiat); const showFiat = overrides.showFiat ?? userPrefersShownFiat; @@ -53,7 +84,7 @@ export function useTokenFiatAmount( () => getTokenFiatAmount( tokenExchangeRate, - conversionRate, + tokenConversionRate, currentCurrency, tokenAmount, tokenSymbol, @@ -61,8 +92,8 @@ export function useTokenFiatAmount( hideCurrencySymbol, ), [ + tokenConversionRate, tokenExchangeRate, - conversionRate, currentCurrency, tokenAmount, tokenSymbol, diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 3c49befb6dd3..9f867862ac8f 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -85,6 +85,7 @@ import { getLedgerTransportType, isAddressLedger, getIsUnlocked, + getCompletedOnboarding, } from '../ducks/metamask/metamask'; import { getLedgerWebHidConnectedStatus, @@ -629,6 +630,10 @@ export const getMarketData = (state) => { return state.metamask.marketData; }; +export function getCurrencyRates(state) { + return state.metamask.currencyRates; +} + export function getAddressBook(state) { const chainId = getCurrentChainId(state); if (!state.metamask.addressBook[chainId]) { @@ -2398,6 +2403,41 @@ export function getDetectedTokensInCurrentNetwork(state) { return state.metamask.allDetectedTokens?.[currentChainId]?.[selectedAddress]; } +export function getAllDetectedTokens(state) { + return state.metamask.allDetectedTokens; +} + +/** + * To retrieve the list of tokens detected across all chains. + * + * @param {*} state + * @returns list of token objects on all networks + */ +export function getAllDetectedTokensForSelectedAddress(state) { + const completedOnboarding = getCompletedOnboarding(state); + + if (!completedOnboarding) { + return {}; + } + + const { address: selectedAddress } = getSelectedInternalAccount(state); + + const tokensByChainId = Object.entries( + state.metamask.allDetectedTokens || {}, + ).reduce((acc, [chainId, chainTokens]) => { + const tokensForAddress = chainTokens[selectedAddress]; + if (tokensForAddress) { + acc[chainId] = tokensForAddress.map((token) => ({ + ...token, + chainId, + })); + } + return acc; + }, {}); + + return tokensByChainId; +} + /** * To fetch the name of the tokens that are imported from tokens found page * diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 10391adad3df..c16df9838916 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -1013,7 +1013,9 @@ describe('Actions', () => { const store = mockStore(); background.getApi.returns({ - ignoreTokens: sinon.stub().callsFake((_, cb) => cb(new Error('error'))), + ignoreTokens: sinon + .stub() + .callsFake((_, __, cb) => cb(new Error('error'))), getStatePatches: sinon.stub().callsFake((cb) => cb(null, [])), }); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 6344f823aa02..92208e6c48b4 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -1989,14 +1989,17 @@ export function addImportedTokens( * * @param options * @param options.tokensToIgnore + * @param options.networkClientId * @param options.dontShowLoadingIndicator */ export function ignoreTokens({ tokensToIgnore, dontShowLoadingIndicator = false, + networkClientId = null, }: { tokensToIgnore: string[]; dontShowLoadingIndicator: boolean; + networkClientId?: NetworkClientId; }): ThunkAction { const _tokensToIgnore = Array.isArray(tokensToIgnore) ? tokensToIgnore @@ -2007,7 +2010,10 @@ export function ignoreTokens({ dispatch(showLoadingIndication()); } try { - await submitRequestToBackground('ignoreTokens', [_tokensToIgnore]); + await submitRequestToBackground('ignoreTokens', [ + _tokensToIgnore, + networkClientId, + ]); } catch (error) { logErrorWithMessage(error); dispatch(displayWarning(error)); diff --git a/yarn.lock b/yarn.lock index 0147c3106fa4..5dc2340b7c99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4934,9 +4934,9 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:44.0.0": - version: 44.0.0 - resolution: "@metamask/assets-controllers@npm:44.0.0" +"@metamask/assets-controllers@npm:44.1.0": + version: 44.1.0 + resolution: "@metamask/assets-controllers@npm:44.1.0" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -4969,13 +4969,13 @@ __metadata: "@metamask/keyring-controller": ^18.0.0 "@metamask/network-controller": ^22.0.0 "@metamask/preferences-controller": ^14.0.0 - checksum: 10/6f3d8712a90aa322aabd38d43663d299ad7ee98a6d838d72bfc3b426ea0e4e925bb78c1aaaa3c75d43e95d46993c47583a4a03f4c58aee155525424fa86207ae + checksum: 10/924c67fba204711ddde4be6615359318ed0fbdd05ebd8e5d98ae9d9ae288adad5cb6fc901b91d8e84f92a6ab62f0bfb25601b03c676044009f81a7fffa8087e7 languageName: node linkType: hard -"@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" +"@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch": + version: 44.1.0 + resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch::version=44.1.0&hash=611d79" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -5008,46 +5008,7 @@ __metadata: "@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": ^14.0.0 - checksum: 10/11e8920bdf8ffce4a534c6aadfe768176c4e461a00bc06e6ece52f085755ff252194881d9edd308097186a05057075fd9812b6e4b1fd97dd731814ad205013da + checksum: 10/6927a6af16d6e83a488a3f82e71fbf78b016728009672df12937411cfe7fa087bef34fd2642e04e2038c0baba91d64b9315c54800059e8dddeb31a06ebf25e1e languageName: node linkType: hard @@ -26819,7 +26780,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@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/assets-controllers": "patch:@metamask/assets-controllers@npm%3A44.1.0#~/.yarn/patches/@metamask-assets-controllers-npm-44.1.0-012aa448d8.patch" "@metamask/auto-changelog": "npm:^2.1.0" "@metamask/base-controller": "npm:^7.0.0" "@metamask/bitcoin-wallet-snap": "npm:^0.8.2"