From 224efedaa6939e101f0710bedf455ff9b3e10269 Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Mon, 11 Sep 2023 20:13:53 -0230 Subject: [PATCH] refactor: Key the address book by chain ID instead of network ID The `AddressBookController` stores addresses by chain ID, but we had been storing and retrieving them using the network ID instead in many places. It has been updated to consistently use the chain ID for all address book access. Existing address book entries have been migrated to be grouped by chain ID. For all known networks, we attempt to map the network ID to a chain ID that exists as a locally configured or built-in network. In cases where multiple matches are found, the entries are duplicated on each matching chain. Address book entries that don't correspond with any local networks are discarded. This relates to https://github.com/MetaMask/mobile-planning/issues/1226 --- .../AddToAddressBookWrapper.tsx | 6 +- .../AddNickname/index.tsx | 10 +- .../Views/ApproveView/Approve/index.js | 9 +- app/components/Views/Send/index.js | 18 +- .../SendFlow/AddressList/AddressList.tsx | 10 +- app/components/Views/SendFlow/SendTo/index.js | 27 +- .../Settings/Contacts/ContactForm/index.js | 25 +- .../Views/Settings/Contacts/index.js | 18 +- .../hooks/useExistingAddress.test.ts | 4 +- app/components/hooks/useExistingAddress.ts | 6 +- app/reducers/user/index.js | 1 + .../migration-data/amibiguous-networks.json | 80 ++++ app/store/migrations.js | 188 ++++++++ app/store/migrations.test.js | 413 +++++++++++++++++- app/util/address/index.js | 14 +- app/util/checkAddress.test.ts | 28 +- app/util/checkAddress.ts | 33 +- app/util/test/initial-root-state.ts | 2 +- app/util/transactions/index.js | 6 +- package.json | 1 + 20 files changed, 781 insertions(+), 118 deletions(-) create mode 100644 app/store/migration-data/amibiguous-networks.json diff --git a/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx b/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx index 626c28f488e9..24a084ce08a8 100644 --- a/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx +++ b/app/components/UI/AddToAddressBookWrapper/AddToAddressBookWrapper.tsx @@ -13,7 +13,7 @@ import { strings } from '../../../../locales/i18n'; import Engine from '../../../core/Engine'; import { ADD_ADDRESS_MODAL_CONTAINER_ID } from '../../../constants/test-ids'; import { baseStyles } from '../../../styles/common'; -import { selectNetwork } from '../../../selectors/networkController'; +import { selectChainId } from '../../../selectors/networkController'; import { useTheme } from '../../../util/theme'; import Text from '../../Base/Text'; import useExistingAddress from '../../hooks/useExistingAddress'; @@ -36,7 +36,7 @@ export const AddToAddressBookWrapper = ({ setToAddressName, defaultNull = false, }: AddToAddressBookWrapperProps) => { - const networkId = useSelector(selectNetwork); + const chainId = useSelector(selectChainId); const existingContact = useExistingAddress(address); const { colors, themeAppearance } = useTheme(); @@ -53,7 +53,7 @@ export const AddToAddressBookWrapper = ({ const onSaveToAddressBook = () => { const { AddressBookController } = Engine.context; - AddressBookController.set(address, alias, networkId); + AddressBookController.set(address, alias, chainId); !!alias && setToAddressName?.(alias); setAlias(undefined); }; diff --git a/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx b/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx index a0b2d9304f99..dd055ab51a9d 100644 --- a/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx +++ b/app/components/UI/ApproveTransactionReview/AddNickname/index.tsx @@ -33,7 +33,6 @@ import { } from '../../../../constants/error'; import { selectChainId, - selectNetwork, selectNetworkConfigurations, selectProviderType, selectRpcTarget, @@ -50,7 +49,6 @@ const AddNickname = (props: AddNicknameProps) => { addressNickname, providerType, providerChainId, - providerNetwork, providerRpcTarget, addressBook, identities, @@ -75,17 +73,16 @@ const AddNickname = (props: AddNicknameProps) => { const validateAddressOrENSFromInput = useCallback(async () => { const { addressError, errorContinue } = await validateAddressOrENS({ toAccount: address, - // TODO: This parameters is effectively ignored, it should be named `networkId` - providerNetwork, addressBook, identities, + // TODO: This parameters is effectively ignored, it should be named `chainId` providerChainId, }); setAddressErr(addressError); setErrContinue(errorContinue); setAddressHasError(addressError); - }, [address, providerNetwork, addressBook, identities, providerChainId]); + }, [address, addressBook, identities, providerChainId]); useEffect(() => { validateAddressOrENSFromInput(); @@ -123,7 +120,7 @@ const AddNickname = (props: AddNicknameProps) => { AddressBookController.set( toChecksumAddress(address), newNickname, - providerNetwork, + providerChainId, ); closeModal(); AnalyticsV2.trackEvent( @@ -264,7 +261,6 @@ const mapStateToProps = (state: any) => ({ providerType: selectProviderType(state), providerRpcTarget: selectRpcTarget(state), providerChainId: selectChainId(state), - providerNetwork: selectNetwork(state), addressBook: state.engine.backgroundState.AddressBookController.addressBook, identities: selectIdentities(state), networkConfigurations: selectNetworkConfigurations(state), diff --git a/app/components/Views/ApproveView/Approve/index.js b/app/components/Views/ApproveView/Approve/index.js index 7dcb0aabecfa..bfc5d702eafc 100644 --- a/app/components/Views/ApproveView/Approve/index.js +++ b/app/components/Views/ApproveView/Approve/index.js @@ -46,7 +46,6 @@ import { } from '../../../../core/GasPolling/GasPolling'; import { selectChainId, - selectNetwork, selectProviderType, selectTicker, selectRpcTarget, @@ -139,10 +138,6 @@ class Approve extends PureComponent { * An object of all saved addresses */ addressBook: PropTypes.object, - /** - * The current network of the app - */ - networkId: PropTypes.string, networkConfigurations: PropTypes.object, providerRpcTarget: PropTypes.string, /** @@ -649,7 +644,6 @@ class Approve extends PureComponent { const { transaction, addressBook, - networkId, gasEstimateType, gasFeeEstimates, primaryCurrency, @@ -678,7 +672,7 @@ class Approve extends PureComponent { const savedContactList = checkIfAddressIsSaved( addressBook, - networkId, + chainId, transaction, ); @@ -848,7 +842,6 @@ const mapStateToProps = (state) => ({ nativeCurrency: selectNativeCurrency(state), showCustomNonce: state.settings.showCustomNonce, addressBook: state.engine.backgroundState.AddressBookController.addressBook, - networkId: selectNetwork(state), providerType: selectProviderType(state), providerRpcTarget: selectRpcTarget(state), networkConfigurations: selectNetworkConfigurations(state), diff --git a/app/components/Views/Send/index.js b/app/components/Views/Send/index.js index d4afec7242f3..ccd3be494681 100644 --- a/app/components/Views/Send/index.js +++ b/app/components/Views/Send/index.js @@ -48,7 +48,6 @@ import { KEYSTONE_TX_CANCELED } from '../../../constants/error'; import { ThemeContext, mockTheme } from '../../../util/theme'; import { selectChainId, - selectNetwork, selectProviderType, } from '../../../selectors/networkController'; import { selectTokenList } from '../../../selectors/tokenListController'; @@ -116,10 +115,6 @@ class Send extends PureComponent { * Map representing the address book */ addressBook: PropTypes.object, - /** - * Network id - */ - networkId: PropTypes.string, /** * The chain ID of the current selected network */ @@ -309,7 +304,7 @@ class Send extends PureComponent { function_name = null, // eslint-disable-line no-unused-vars parameters = null, }) => { - const { addressBook, networkId, identities, selectedAddress } = this.props; + const { addressBook, chainId, identities, selectedAddress } = this.props; let newTxMeta = {}; let txRecipient; @@ -334,7 +329,7 @@ class Send extends PureComponent { newTxMeta.transactionToName = getTransactionToName({ addressBook, - networkId, + chainId, toAddress: newTxMeta.to, identities, ensRecipient: newTxMeta.ensRecipient, @@ -374,7 +369,7 @@ class Send extends PureComponent { }; newTxMeta.transactionToName = getTransactionToName({ addressBook, - networkId, + chainId, toAddress: to, identities, ensRecipient, @@ -545,7 +540,7 @@ class Send extends PureComponent { this.setState({ transactionConfirmed: true }); const { transaction: { selectedAsset, assetType }, - networkId, + chainId, addressBook, } = this.props; let { transaction } = this.props; @@ -597,9 +592,9 @@ class Send extends PureComponent { } } const existingContact = - addressBook[networkId] && addressBook[networkId][checksummedAddress]; + addressBook[chainId] && addressBook[chainId][checksummedAddress]; if (!existingContact) { - AddressBookController.set(checksummedAddress, '', networkId); + AddressBookController.set(checksummedAddress, '', chainId); } await new Promise((resolve) => { resolve(result); @@ -774,7 +769,6 @@ const mapStateToProps = (state) => ({ transaction: state.transaction, networkType: selectProviderType(state), tokens: selectTokens(state), - networkId: selectNetwork(state), chainId: selectChainId(state), identities: selectIdentities(state), selectedAddress: selectSelectedAddress(state), diff --git a/app/components/Views/SendFlow/AddressList/AddressList.tsx b/app/components/Views/SendFlow/AddressList/AddressList.tsx index df35d9a23998..0cfe876e6872 100644 --- a/app/components/Views/SendFlow/AddressList/AddressList.tsx +++ b/app/components/Views/SendFlow/AddressList/AddressList.tsx @@ -15,7 +15,7 @@ import AddressElement from '../AddressElement'; import { useTheme } from '../../../../util/theme'; import Text from '../../../../component-library/components/Texts/Text/Text'; import { TextVariant } from '../../../../component-library/components/Texts/Text'; -import { selectNetwork } from '../../../../selectors/networkController'; +import { selectChainId } from '../../../../selectors/networkController'; import { selectIdentities } from '../../../../selectors/preferencesController'; // Internal dependencies @@ -45,7 +45,7 @@ const AddressList: React.FC = ({ const styles = styleSheet(colors); const [contactElements, setContactElements] = useState([]); const [fuse, setFuse] = useState(undefined); - const networkId = useSelector(selectNetwork); + const chainId = useSelector(selectChainId); const identities = useSelector(selectIdentities); const addressBook = useSelector( (state: any) => @@ -53,8 +53,8 @@ const AddressList: React.FC = ({ ); const networkAddressBook: { [address: string]: AddressBookEntry } = useMemo( - () => addressBook[networkId] || {}, - [addressBook, networkId], + () => addressBook[chainId] || {}, + [addressBook, chainId], ); const parseAddressBook = useCallback( @@ -145,7 +145,7 @@ const AddressList: React.FC = ({ }, [ inputSearch, addressBook, - networkId, + chainId, reloadAddressList, getNetworkAddressBookList, parseAddressBook, diff --git a/app/components/Views/SendFlow/SendTo/index.js b/app/components/Views/SendFlow/SendTo/index.js index b3f5c3d6bdc5..6896b0cb7ce1 100644 --- a/app/components/Views/SendFlow/SendTo/index.js +++ b/app/components/Views/SendFlow/SendTo/index.js @@ -60,7 +60,6 @@ import { import generateTestId from '../../../../../wdio/utils/generateTestId'; import { selectChainId, - selectNetwork, selectProviderType, selectTicker, } from '../../../../selectors/networkController'; @@ -89,10 +88,6 @@ class SendFlow extends PureComponent { * Network provider chain id */ chainId: PropTypes.string, - /** - * Network id - */ - networkId: PropTypes.string, /** * Object that represents the navigator */ @@ -181,7 +176,7 @@ class SendFlow extends PureComponent { const { addressBook, ticker, - networkId, + chainId, navigation, providerType, route, @@ -190,7 +185,7 @@ class SendFlow extends PureComponent { this.updateNavBar(); // For analytics navigation.setParams({ providerType, isPaymentRequest }); - const networkAddressBook = addressBook[networkId] || {}; + const networkAddressBook = addressBook[chainId] || {}; if (!Object.keys(networkAddressBook).length) { setTimeout(() => { this.addressToInputRef && @@ -223,8 +218,8 @@ class SendFlow extends PureComponent { isAddressSaved = () => { const { toAccount } = this.state; - const { addressBook, networkId, identities } = this.props; - const networkAddressBook = addressBook[networkId] || {}; + const { addressBook, chainId, identities } = this.props; + const networkAddressBook = addressBook[chainId] || {}; const checksummedAddress = toChecksumAddress(toAccount); return !!( networkAddressBook[checksummedAddress] || identities[checksummedAddress] @@ -366,10 +361,10 @@ class SendFlow extends PureComponent { }; getAddressNameFromBookOrIdentities = (toAccount) => { - const { addressBook, identities, networkId } = this.props; + const { addressBook, identities, chainId } = this.props; if (!toAccount) return; - const networkAddressBook = addressBook[networkId] || {}; + const networkAddressBook = addressBook[chainId] || {}; const checksummedAddress = toChecksumAddress(toAccount); @@ -381,7 +376,7 @@ class SendFlow extends PureComponent { }; validateAddressOrENSFromInput = async (toAccount) => { - const { addressBook, identities, chainId, networkId } = this.props; + const { addressBook, identities, chainId } = this.props; const { addressError, toEnsName, @@ -394,7 +389,6 @@ class SendFlow extends PureComponent { confusableCollection, } = await validateAddressOrENS({ toAccount, - networkId, addressBook, identities, chainId, @@ -441,7 +435,7 @@ class SendFlow extends PureComponent { }; render = () => { - const { ticker, addressBook, networkId } = this.props; + const { ticker, addressBook, chainId } = this.props; const { toAccount, toSelectedAddressReady, @@ -464,8 +458,8 @@ class SendFlow extends PureComponent { ); const existingContact = checksummedAddress && - addressBook[networkId] && - addressBook[networkId][checksummedAddress]; + addressBook[chainId] && + addressBook[chainId][checksummedAddress]; const displayConfusableWarning = !existingContact && confusableCollection && !!confusableCollection.length; const displayAsWarning = @@ -636,7 +630,6 @@ const mapStateToProps = (state) => ({ selectedAsset: state.transaction.selectedAsset, identities: selectIdentities(state), ticker: selectTicker(state), - networkId: selectNetwork(state), providerType: selectProviderType(state), isPaymentRequest: state.transaction.paymentRequest, isNativeTokenBuySupported: isNetworkBuyNativeTokenSupported( diff --git a/app/components/Views/Settings/Contacts/ContactForm/index.js b/app/components/Views/Settings/Contacts/ContactForm/index.js index cc1465b3084a..54c880edd67c 100644 --- a/app/components/Views/Settings/Contacts/ContactForm/index.js +++ b/app/components/Views/Settings/Contacts/ContactForm/index.js @@ -32,10 +32,7 @@ import { import Routes from '../../../../../constants/navigation/Routes'; import { createQRScannerNavDetails } from '../../../QRScanner'; import generateTestId from '../../../../../../wdio/utils/generateTestId'; -import { - selectChainId, - selectNetwork, -} from '../../../../../selectors/networkController'; +import { selectChainId } from '../../../../../selectors/networkController'; import { selectIdentities } from '../../../../../selectors/preferencesController'; import { ADD_CONTACT_ADD_BUTTON, @@ -131,10 +128,6 @@ class ContactForm extends PureComponent { * Object that represents the navigator */ navigation: PropTypes.object, - /** - * Network id - */ - networkId: PropTypes.string, /** * An object containing each identity in the format address => account */ @@ -193,8 +186,8 @@ class ContactForm extends PureComponent { this.setState({ inputWidth: '100%' }); }, 100); if (mode === EDIT) { - const { addressBook, networkId, identities } = this.props; - const networkAddressBook = addressBook[networkId] || {}; + const { addressBook, chainId, identities } = this.props; + const networkAddressBook = addressBook[chainId] || {}; const address = this.props.route.params?.address ?? ''; const contact = networkAddressBook[address] || identities[address]; this.setState({ @@ -231,7 +224,7 @@ class ContactForm extends PureComponent { }; validateAddressOrENSFromInput = async (address) => { - const { networkId, addressBook, identities, chainId } = this.props; + const { addressBook, identities, chainId } = this.props; const { addressError, @@ -241,7 +234,6 @@ class ContactForm extends PureComponent { errorContinue, } = await validateAddressOrENS({ toAccount: address, - networkId, addressBook, identities, chainId, @@ -277,13 +269,13 @@ class ContactForm extends PureComponent { saveContact = () => { const { name, address, memo, toEnsAddress } = this.state; - const { networkId, navigation } = this.props; + const { chainId, navigation } = this.props; const { AddressBookController } = Engine.context; if (!name || !address) return; AddressBookController.set( toChecksumAddress(toEnsAddress || address), name, - networkId, + chainId, memo, ); navigation.pop(); @@ -291,8 +283,8 @@ class ContactForm extends PureComponent { deleteContact = () => { const { AddressBookController } = Engine.context; - const { networkId, navigation, route } = this.props; - AddressBookController.delete(networkId, this.contactAddressToRemove); + const { chainId, navigation, route } = this.props; + AddressBookController.delete(chainId, this.contactAddressToRemove); route.params.onDelete(); navigation.pop(); }; @@ -516,7 +508,6 @@ ContactForm.contextType = ThemeContext; const mapStateToProps = (state) => ({ addressBook: state.engine.backgroundState.AddressBookController.addressBook, identities: selectIdentities(state), - networkId: selectNetwork(state), chainId: selectChainId(state), }); diff --git a/app/components/Views/Settings/Contacts/index.js b/app/components/Views/Settings/Contacts/index.js index b64bc6ed19fe..3b5448287a00 100644 --- a/app/components/Views/Settings/Contacts/index.js +++ b/app/components/Views/Settings/Contacts/index.js @@ -9,7 +9,7 @@ import StyledButton from '../../../UI/StyledButton'; import Engine from '../../../../core/Engine'; import ActionSheet from 'react-native-actionsheet'; import { mockTheme, ThemeContext } from '../../../../util/theme'; -import { selectNetwork } from '../../../../selectors/networkController'; +import { selectChainId } from '../../../../selectors/networkController'; import generateTestId from '../../../../../wdio/utils/generateTestId'; import { @@ -47,9 +47,9 @@ class Contacts extends PureComponent { */ navigation: PropTypes.object, /** - * Network id + * The chain ID for the current selected network */ - networkId: PropTypes.string, + chainId: PropTypes.string, }; state = { @@ -78,12 +78,12 @@ class Contacts extends PureComponent { componentDidUpdate = (prevProps) => { this.updateNavBar(); - const { networkId } = this.props; + const { chainId } = this.props; if ( prevProps.addressBook && this.props.addressBook && - JSON.stringify(prevProps.addressBook[networkId]) !== - JSON.stringify(this.props.addressBook[networkId]) + JSON.stringify(prevProps.addressBook[chainId]) !== + JSON.stringify(this.props.addressBook[chainId]) ) this.updateAddressList(); }; @@ -102,8 +102,8 @@ class Contacts extends PureComponent { deleteContact = () => { const { AddressBookController } = Engine.context; - const { networkId } = this.props; - AddressBookController.delete(networkId, this.contactAddressToRemove); + const { chainId } = this.props; + AddressBookController.delete(chainId, this.contactAddressToRemove); this.updateAddressList(); }; @@ -171,7 +171,7 @@ Contacts.contextType = ThemeContext; const mapStateToProps = (state) => ({ addressBook: state.engine.backgroundState.AddressBookController.addressBook, - networkId: selectNetwork(state), + chainId: selectChainId(state), }); export default connect(mapStateToProps)(Contacts); diff --git a/app/components/hooks/useExistingAddress.test.ts b/app/components/hooks/useExistingAddress.test.ts index de021f5066bc..b58ed16fd8e6 100644 --- a/app/components/hooks/useExistingAddress.test.ts +++ b/app/components/hooks/useExistingAddress.test.ts @@ -17,7 +17,9 @@ const mockInitialState = { }, }, NetworkController: { - network: 1, + providerConfig: { + chainId: '1', + }, }, AddressBookController: { addressBook: { diff --git a/app/components/hooks/useExistingAddress.ts b/app/components/hooks/useExistingAddress.ts index bfe502449fb8..82477f0c2140 100644 --- a/app/components/hooks/useExistingAddress.ts +++ b/app/components/hooks/useExistingAddress.ts @@ -1,7 +1,7 @@ import { toChecksumAddress } from 'ethereumjs-util'; import { useSelector } from 'react-redux'; -import { selectNetwork } from '../../selectors/networkController'; +import { selectChainId } from '../../selectors/networkController'; import { selectIdentities } from '../../selectors/preferencesController'; export interface Address { @@ -14,7 +14,7 @@ export interface Address { } const useExistingAddress = (address?: string): Address | undefined => { - const networkId = useSelector(selectNetwork); + const chainId = useSelector(selectChainId); const { addressBook, identities } = useSelector((state: any) => ({ addressBook: state.engine.backgroundState.AddressBookController.addressBook, identities: selectIdentities(state), @@ -22,7 +22,7 @@ const useExistingAddress = (address?: string): Address | undefined => { if (!address) return; - const networkAddressBook = addressBook[networkId] || {}; + const networkAddressBook = addressBook[chainId] || {}; const checksummedAddress = toChecksumAddress(address); return ( diff --git a/app/reducers/user/index.js b/app/reducers/user/index.js index 90f640e6a9b0..08c58beb74e4 100644 --- a/app/reducers/user/index.js +++ b/app/reducers/user/index.js @@ -13,6 +13,7 @@ const initialState = { isAuthChecked: false, initialScreen: '', appTheme: AppThemeKey.os, + ambiguousAddressEntries: {}, }; const userReducer = (state = initialState, action) => { diff --git a/app/store/migration-data/amibiguous-networks.json b/app/store/migration-data/amibiguous-networks.json new file mode 100644 index 000000000000..5428a7027a9b --- /dev/null +++ b/app/store/migration-data/amibiguous-networks.json @@ -0,0 +1,80 @@ +{ + "0": { + "chainIds": ["24", "211", "980", "989"] + }, + "1": { + "chainIds": [ + "1", + "2", + "61", + "101", + "138", + "262", + "820", + "1856", + "1898", + "31102", + "43113", + "71393", + "103090", + "201018", + "201030", + "210425", + "420666", + "2203181", + "2206132", + "20180430" + ] + }, + "2": { + "chainIds": ["9", "62", "821"] + }, + "7": { + "chainIds": ["7", "63"] + }, + "10": { + "chainIds": ["10", "2415"] + }, + "21": { + "chainIds": ["21", "2138"] + }, + "79": { + "chainIds": ["79", "20729"] + }, + "1000": { + "chainIds": ["500", "1000"] + }, + "1001": { + "chainIds": ["501", "1001"] + }, + "1024": { + "chainIds": ["520", "1024"] + }, + "1230": { + "chainIds": ["1230", "12306"] + }, + "2048": { + "chainIds": ["1202", "2048"] + }, + "2221": { + "chainIds": ["222", "2221"] + }, + "3344": { + "chainIds": ["67588"] + }, + "37129": { + "chainIds": ["24484"] + }, + "37480": { + "chainIds": ["24734"] + }, + "48501": { + "chainIds": ["85449"] + }, + "103090": { + "chainIds": ["420420"] + }, + "11235813": { + "chainIds": ["1620"] + } +} diff --git a/app/store/migrations.js b/app/store/migrations.js index d0cdee147a9d..02d8f19fac91 100644 --- a/app/store/migrations.js +++ b/app/store/migrations.js @@ -1,5 +1,8 @@ import { v1 as random, v4 } from 'uuid'; +import { isObject, hasProperty } from '@metamask/utils'; import { NetworksChainId } from '@metamask/controller-utils'; +import { captureException } from '@sentry/react-native'; +import { mapValues } from 'lodash'; import AppConstants from '../core/AppConstants'; import { getAllNetworks, isSafeChainId } from '../util/networks'; import { toLowerCaseEquals } from '../util/general'; @@ -13,6 +16,9 @@ import { } from '../constants/storage'; import { GOERLI, IPFS_DEFAULT_GATEWAY_URL } from '../../app/constants/network'; +// Generated using this script: https://gist.github.com/Gudahtt/7a8a9e452bd2efdc5ceecd93610a25d3 +import ambiguousNetworks from './migration-data/amibiguous-networks.json'; + export const migrations = { // Needed after https://github.com/MetaMask/controllers/pull/152 0: (state) => { @@ -493,6 +499,188 @@ export const migrations = { } return state; }, + /** + * Migrate address book state to be keyed by chain ID rather than network ID. + * + * When choosing which chain ID to migrate each address book entry to, we + * consider only networks that the user has configured locally. Any entries + * for chains not configured locally are discarded. + * + * If there are multiple chain ID candidates for a given network ID (even + * after filtering to include just locally configured networks), address + * entries are duplicated on all potentially matching chains. These cases are + * also stored in the `user.ambiguousAddressEntries` state so that we can + * warn the user in the UI about these addresses. + * + * Note: the type is wrong here because it conflicts with `redux-persist` + * types, due to a bug in that package. + * See: https://github.com/rt2zz/redux-persist/issues/1065 + * TODO: Use `unknown` as the state type, and silence or work around the + * redux-persist bug somehow. + * + * @param {any} state - Redux state. + * @returns Migrated Redux state. + */ + 22: (state) => { + const networkControllerState = + state.engine.backgroundState.NetworkController; + const addressBookControllerState = + state.engine.backgroundState.AddressBookController; + + if (!isObject(networkControllerState)) { + captureException( + new Error( + `Migration 22: Invalid network controller state: '${typeof networkControllerState}'`, + ), + ); + return state; + } else if ( + !hasProperty(networkControllerState, 'networkConfigurations') || + !isObject(networkControllerState.networkConfigurations) + ) { + captureException( + new Error( + `Migration 22: Invalid network configuration state: '${typeof networkControllerState.networkConfigurations}'`, + ), + ); + return state; + } else if ( + Object.values(networkControllerState.networkConfigurations).some( + (networkConfiguration) => !hasProperty(networkConfiguration, 'chainId'), + ) + ) { + const [invalidConfigurationId, invalidConfiguration] = Object.entries( + networkControllerState.networkConfigurations, + ).find( + ([_networkConfigId, networkConfiguration]) => + !hasProperty(networkConfiguration, 'chainId'), + ); + captureException( + new Error( + `Migration 22: Network configuration missing chain ID, id '${invalidConfigurationId}', keys '${Object.keys( + invalidConfiguration, + )}'`, + ), + ); + return state; + } else if (!isObject(addressBookControllerState)) { + captureException( + new Error( + `Migration 22: Invalid address book controller state: '${typeof addressBookControllerState}'`, + ), + ); + return state; + } else if ( + !hasProperty(addressBookControllerState, 'addressBook') || + !isObject(addressBookControllerState.addressBook) + ) { + captureException( + new Error( + `Migration 22: Invalid address book state: '${typeof addressBookControllerState.addressBook}'`, + ), + ); + return state; + } else if ( + Object.values(addressBookControllerState.addressBook).some( + (addressEntries) => !isObject(addressEntries), + ) + ) { + const [networkId, invalidEntries] = Object.entries( + addressBookControllerState.addressBook, + ).find(([_networkId, addressEntries]) => !isObject(addressEntries)); + captureException( + new Error( + `Migration 22: Address book configuration invalid, network id '${networkId}', type '${typeof invalidEntries}'`, + ), + ); + return state; + } else if ( + Object.values(addressBookControllerState.addressBook).some( + (addressEntries) => + Object.values(addressEntries).some( + (addressEntry) => !hasProperty(addressEntry, 'chainId'), + ), + ) + ) { + const [networkId, invalidEntries] = Object.entries( + addressBookControllerState.addressBook, + ).find(([_networkId, addressEntries]) => + Object.values(addressEntries).some( + (addressEntry) => !hasProperty(addressEntry, 'chainId'), + ), + ); + const invalidEntry = Object.values(invalidEntries).find( + (addressEntry) => !hasProperty(addressEntry, 'chainId'), + ); + captureException( + new Error( + `Migration 22: Address book configuration entry missing chain ID, network id '${networkId}', keys '${Object.keys( + invalidEntry, + )}'`, + ), + ); + return state; + } else if (!isObject(state.user)) { + captureException( + new Error(`Migration 22: Invalid user state: '${typeof state.user}'`), + ); + return state; + } + + const localChainIds = Object.values( + networkControllerState.networkConfigurations, + ).reduce((customChainIds, networkConfiguration) => { + customChainIds.add(networkConfiguration.chainId); + return customChainIds; + }, new Set()); + const builtInNetworkChainIdsAsOfMigration22 = [ + '1', + '5', + '11155111', + '59140', + '59144', + ]; + for (const builtInChainId of builtInNetworkChainIdsAsOfMigration22) { + localChainIds.add(builtInChainId); + } + + const migratedAddressBook = {}; + const ambiguousAddressEntries = {}; + for (const [networkId, addressEntries] of Object.entries( + addressBookControllerState.addressBook, + )) { + if (ambiguousNetworks[networkId]) { + const chainIdCandidates = ambiguousNetworks[networkId].chainIds; + const recognizedChainIdCandidates = chainIdCandidates.filter( + (chainId) => localChainIds.has(chainId), + ); + + if (recognizedChainIdCandidates.length > 1) { + for (const chainId of recognizedChainIdCandidates) { + ambiguousAddressEntries[chainId] = Object.keys(addressEntries); + } + } + + for (const chainId of recognizedChainIdCandidates) { + migratedAddressBook[chainId] = mapValues(addressEntries, (entry) => ({ + ...entry, + chainId, + })); + } + } else { + migratedAddressBook[networkId] = addressEntries; + } + } + + addressBookControllerState.addressBook = migratedAddressBook; + + // Store ambiguous entries so that we can warn about them in the UI + if (Object.keys(ambiguousAddressEntries).length > 1) { + state.user.ambiguousAddressEntries = ambiguousAddressEntries; + } + + return state; + }, // If you are implementing a migration it will break the migration tests, // please write a unit for your specific migration version }; diff --git a/app/store/migrations.test.js b/app/store/migrations.test.js index 0b6e92d58903..e25d00ef1314 100644 --- a/app/store/migrations.test.js +++ b/app/store/migrations.test.js @@ -1,8 +1,10 @@ -import { cloneDeep } from 'lodash'; +import { cloneDeep, merge } from 'lodash'; import { v4 } from 'uuid'; import { migrations, version } from './migrations'; import initialBackgroundState from '../util/test/initial-background-state.json'; +import initialRootState from '../util/test/initial-root-state'; import { IPFS_DEFAULT_GATEWAY_URL } from '../../app/constants/network'; +import { captureException } from '@sentry/react-native'; jest.mock('uuid', () => { const actual = jest.requireActual('uuid'); @@ -13,7 +15,18 @@ jest.mock('uuid', () => { }; }); +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +const mockedCaptureException = jest.mocked(captureException); + describe('Redux Persist Migrations', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); + it('should have all migrations up to the latest version', () => { // Assert that the latest migration index matches the version constant expect(Object.keys(migrations).length - 1).toBe(version); @@ -366,4 +379,402 @@ describe('Redux Persist Migrations', () => { expect(newState).toStrictEqual(stateWithoutPreferencesController); }); }); + + describe('#22', () => { + const invalidBackgroundStates = [ + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + NetworkController: null, + }, + }, + }), + errorMessage: + "Migration 22: Invalid network controller state: 'object'", + scenario: 'network controller state is invalid', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + NetworkController: { networkConfigurations: null }, + }, + }, + }), + errorMessage: + "Migration 22: Invalid network configuration state: 'object'", + scenario: 'network configuration state is invalid', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + NetworkController: { + networkConfigurations: { mockNetworkConfigurationId: {} }, + }, + }, + }, + }), + errorMessage: + "Migration 22: Network configuration missing chain ID, id 'mockNetworkConfigurationId', keys ''", + scenario: 'network configuration has entry missing chain ID', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: null, + }, + }, + }), + errorMessage: + "Migration 22: Invalid address book controller state: 'object'", + scenario: 'address book controller state is invalid', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { addressBook: null }, + }, + }, + }), + errorMessage: "Migration 22: Invalid address book state: 'object'", + scenario: 'address book state is invalid', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { addressBook: { 1337: null } }, + }, + }, + }), + errorMessage: + "Migration 22: Address book configuration invalid, network id '1337', type 'object'", + scenario: 'address book network entry is invalid', + }, + { + state: merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + 1337: { '0x0000000000000000000000000000000000000001': {} }, + }, + }, + }, + }, + }), + errorMessage: + "Migration 22: Address book configuration entry missing chain ID, network id '1337', keys ''", + scenario: 'address book entry missing chain ID', + }, + { + state: merge({}, initialRootState, { + user: null, + }), + errorMessage: "Migration 22: Invalid user state: 'object'", + scenario: 'user state is invalid', + }, + ]; + + for (const { errorMessage, scenario, state } of invalidBackgroundStates) { + it(`should capture exception if ${scenario}`, () => { + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState).toStrictEqual(state); + expect(mockedCaptureException).toHaveBeenCalledWith(expect.any(Error)); + expect(mockedCaptureException.mock.calls[0][0].message).toBe( + errorMessage, + ); + }); + } + + it('should not change state if address book is empty', () => { + const state = merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { addressBook: {} }, + }, + }, + }); + + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState).toStrictEqual(state); + }); + + it('should not change state if there are no ambiguous network IDs', () => { + const state = merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + 11155111: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock', + chainId: '11155111', + memo: '', + isEns: false, + }, + }, + }, + }, + }, + }, + }); + + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState).toStrictEqual(state); + }); + + it('should migrate ambiguous network IDs based on available networks configured locally', () => { + const state = merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + // This is an ambiguous built-in network (other chains share this network ID) + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // This is an ambiguous custom network (other chains share this network ID) + 10: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '10', + memo: '', + isEns: false, + }, + }, + }, + }, + NetworkController: { + networkConfigurations: { + mockNetworkConfigurationId: { + id: 'mockNetworkConfigurationId', + rpcUrl: 'https://fake-url.metamask.io', + // 2415 is the chain ID for a network with the network ID "10" + chainId: '2415', + ticker: 'ETH', + }, + }, + }, + }, + }, + }); + + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState.user).toStrictEqual({}); + expect(newState.engine.backgroundState).toStrictEqual( + merge({}, initialBackgroundState, { + AddressBookController: { + addressBook: { + // This is unchanged because the only configured network with a network ID 1 also has + // a chain ID of 1. + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // This has been updated from 10 to 2415 according to the one configured local network + // with a network ID of 2415 + 2415: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '2415', + memo: '', + isEns: false, + }, + }, + }, + }, + NetworkController: state.engine.backgroundState.NetworkController, + }), + ); + }); + + it('should duplicate entries where multiple configured networks match network ID', () => { + const state = merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + // This is an ambiguous built-in network (other chains share this network ID) + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // This is an ambiguous custom network (other chains share this network ID) + 10: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '10', + memo: '', + isEns: false, + }, + }, + }, + }, + NetworkController: { + networkConfigurations: { + mockNetworkConfigurationId1: { + id: 'mockNetworkConfigurationId1', + rpcUrl: 'https://fake-url1.metamask.io', + // 10 is the chain ID for a network with the network ID "10" + chainId: '10', + ticker: 'ETH', + }, + mockNetworkConfigurationId2: { + id: 'mockNetworkConfigurationId2', + rpcUrl: 'https://fake-url2.metamask.io', + // 2415 is the chain ID for a network with the network ID "10" + chainId: '2415', + ticker: 'ETH', + }, + }, + }, + }, + }, + }); + + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState.user).toStrictEqual({ + ambiguousAddressEntries: { + 10: ['0x0000000000000000000000000000000000000002'], + 2415: ['0x0000000000000000000000000000000000000002'], + }, + }); + expect(newState.engine.backgroundState).toStrictEqual( + merge({}, initialBackgroundState, { + AddressBookController: { + addressBook: { + // This is unchanged because the only configured network with a network ID 1 also has + // a chain ID of 1. + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // The entry for 10 has been duplicated across both locally configured networks that + // have a matching network ID: 10 and 2415 + 10: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '10', + memo: '', + isEns: false, + }, + }, + 2415: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '2415', + memo: '', + isEns: false, + }, + }, + }, + }, + NetworkController: state.engine.backgroundState.NetworkController, + }), + ); + }); + + it('should discard address book entries that do not match any configured networks', () => { + const state = merge({}, initialRootState, { + engine: { + backgroundState: { + AddressBookController: { + addressBook: { + // This is an ambiguous built-in network (other chains share this network ID) + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // This is an ambiguous custom network (other chains share this network ID) + 10: { + '0x0000000000000000000000000000000000000002': { + address: '0x0000000000000000000000000000000000000002', + name: 'Mock2', + chainId: '10', + memo: '', + isEns: false, + }, + }, + }, + }, + NetworkController: { + networkConfigurations: {}, + }, + }, + }, + }); + + const migration = migrations[22]; + const newState = migration(cloneDeep(state)); + + expect(newState.user).toStrictEqual({}); + expect(newState.engine.backgroundState).toStrictEqual( + merge({}, initialBackgroundState, { + AddressBookController: { + addressBook: { + // This is unchanged because the only configured network with a network ID 1 also has + // a chain ID of 1. + 1: { + '0x0000000000000000000000000000000000000001': { + address: '0x0000000000000000000000000000000000000001', + name: 'Mock1', + chainId: '1', + memo: '', + isEns: false, + }, + }, + // The entry for 10 has been removed because it had no local matches + }, + }, + }), + ); + }); + }); }); diff --git a/app/util/address/index.js b/app/util/address/index.js index 30c725568ea5..89623dcce84a 100644 --- a/app/util/address/index.js +++ b/app/util/address/index.js @@ -290,13 +290,13 @@ export function isValidHexAddress( * address (String) - Represents the address of the account * addressBook (Object) - Represents all the contacts that we have saved on the address book * identities (Object) - Represents our accounts on the current network of the wallet - * networkId (string) - The current network ID + * chainId (string) - The chain ID for the current selected network * @returns String | undefined - When it is saved returns a string "contactAlreadySaved" if it's not reutrn undefined */ function checkIfAddressAlreadySaved(params) { - const { address, addressBook, networkId, identities } = params; + const { address, addressBook, chainId, identities } = params; if (address) { - const networkAddressBook = addressBook[networkId] || {}; + const networkAddressBook = addressBook[chainId] || {}; const checksummedResolvedAddress = toChecksumAddress(address); if ( @@ -316,7 +316,7 @@ function checkIfAddressAlreadySaved(params) { * is present in ContactForm of Contatcs, in order to add a new contact * Variables: * toAccount (String) - Represents the account address or ens - * networkId (String) - Represents the current network ID + * chainId (String) - Represents the current chain ID * addressBook (Object) - Represents all the contacts that we have saved on the address book * identities (Object) - Represents our accounts on the current network of the wallet * providerType (String) - Represents the network name @@ -333,7 +333,7 @@ function checkIfAddressAlreadySaved(params) { * */ export async function validateAddressOrENS(params) { - const { toAccount, networkId, addressBook, identities, chainId } = params; + const { toAccount, addressBook, identities, chainId } = params; const { AssetsContractController } = Engine.context; let addressError, @@ -349,7 +349,7 @@ export async function validateAddressOrENS(params) { const contactAlreadySaved = checkIfAddressAlreadySaved({ address: toAccount, addressBook, - networkId, + chainId, identities, }); @@ -406,7 +406,7 @@ export async function validateAddressOrENS(params) { const contactAlreadySaved = checkIfAddressAlreadySaved({ address: resolvedAddress, addressBook, - networkId, + chainId, identities, }); diff --git a/app/util/checkAddress.test.ts b/app/util/checkAddress.test.ts index dedb8a0a9d09..e20a55976e2d 100644 --- a/app/util/checkAddress.test.ts +++ b/app/util/checkAddress.test.ts @@ -15,11 +15,11 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = {}; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([]); }); @@ -27,13 +27,13 @@ describe('checkIfAddressIsSaved', () => { it('returns undefined if the address book is empty', () => { const mockAddress = '0x0000000000000000000000000000000000000001'; const addressBook: AddressBookState['addressBook'] = {}; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toBeUndefined(); }); @@ -51,13 +51,13 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress1, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([]); }); @@ -74,13 +74,13 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([]); }); @@ -97,13 +97,13 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([ { address: mockAddress, @@ -126,13 +126,13 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([ { address: mockAddressChecksummed, @@ -163,13 +163,13 @@ describe('checkIfAddressIsSaved', () => { }, }, }; - const networkId = '1'; + const chainId = '1'; const transaction = { to: mockAddress, }; expect( - checkIfAddressIsSaved(addressBook, networkId, transaction), + checkIfAddressIsSaved(addressBook, chainId, transaction), ).toStrictEqual([ { address: mockAddressChecksummed, diff --git a/app/util/checkAddress.ts b/app/util/checkAddress.ts index 4316d157ed83..a73ecd0cdf5c 100644 --- a/app/util/checkAddress.ts +++ b/app/util/checkAddress.ts @@ -1,28 +1,41 @@ import type { AddressBookState } from '@metamask/address-book-controller'; import { toChecksumAddress } from 'ethereumjs-util'; +/** + * Check whether the recipient of the given transaction is included in + * the address book. + * + * @param addressBook - The address book state. + * @param chainId - The chain ID of the current selected network. + * @param transaction - The transaction to check the recipient of. + * @returns Any address book entries that match the current chain ID and + * transaction recipient. + */ const checkIfAddressIsSaved = ( addressBook: AddressBookState['addressBook'], - networkId: string, + chainId: string, transaction: any, ) => { if (transaction.to === undefined) { return []; } - for (const [key, value] of Object.entries(addressBook)) { - const addressValues = Object.values(value).map((val: any) => ({ - address: toChecksumAddress(val.address), - nickname: val.name, + for (const [addressBookChainId, chainAddresses] of Object.entries( + addressBook, + )) { + const addressEntries = Object.values(chainAddresses).map((entry: any) => ({ + address: toChecksumAddress(entry.address), + nickname: entry.name, })); if ( - addressValues.some( - (x) => - x.address === toChecksumAddress(transaction.to) && key === networkId, + addressEntries.some( + (entry) => + entry.address === toChecksumAddress(transaction.to) && + addressBookChainId === chainId, ) ) { - return addressValues.filter( - (x) => x.address === toChecksumAddress(transaction.to), + return addressEntries.filter( + (entry) => entry.address === toChecksumAddress(transaction.to), ); } return []; diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts index b4b4ec511ff9..c4ddb8a25dce 100644 --- a/app/util/test/initial-root-state.ts +++ b/app/util/test/initial-root-state.ts @@ -17,7 +17,7 @@ const initialRootState: RootState = { settings: undefined, alert: undefined, transaction: undefined, - user: undefined, + user: {}, wizard: undefined, onboarding: undefined, notification: undefined, diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js index f97a4179be14..79abd4b93506 100644 --- a/app/util/transactions/index.js +++ b/app/util/transactions/index.js @@ -459,7 +459,7 @@ export function getEther(ticker) { * * @param {object} config * @param {object} config.addressBook - Object of address book entries - * @param {string} config.networkId - network id + * @param {string} config.chainId - network id * @param {string} config.toAddress - hex address of tx recipient * @param {object} config.identities - object of identities * @param {string} config.ensRecipient - name of ens recipient @@ -467,7 +467,7 @@ export function getEther(ticker) { */ export function getTransactionToName({ addressBook, - networkId, + chainId, toAddress, identities, ensRecipient, @@ -476,7 +476,7 @@ export function getTransactionToName({ return ensRecipient; } - const networkAddressBook = addressBook[networkId]; + const networkAddressBook = addressBook[chainId]; const checksummedToAddress = toChecksumAddress(toAddress); const transactionToName = diff --git a/package.json b/package.json index e5e132a81a7e..07b03bfe4d8d 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^6.8.0", "@metamask/transaction-controller": "^4.0.1", + "@metamask/utils": "^5.0.2", "@ngraveio/bc-ur": "^1.1.6", "@react-native-async-storage/async-storage": "1.17.10", "@react-native-clipboard/clipboard": "1.8.4",