diff --git a/ui/components/app/wallet-overview/btc-overview.test.tsx b/ui/components/app/wallet-overview/btc-overview.test.tsx index ffaed244e958..62e6f5ff82b3 100644 --- a/ui/components/app/wallet-overview/btc-overview.test.tsx +++ b/ui/components/app/wallet-overview/btc-overview.test.tsx @@ -11,14 +11,33 @@ import { MultichainNetworks } from '../../../../shared/constants/multichain/netw import { RampsMetaMaskEntry } from '../../../hooks/ramps/useRamps/useRamps'; import { defaultBuyableChains } from '../../../ducks/ramps/constants'; import { setBackgroundConnection } from '../../../store/background-connection'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; import BtcOverview from './btc-overview'; +// We need to mock `dispatch` since we use it for `setDefaultHomeActiveTabName`. +const mockDispatch = jest.fn().mockReturnValue(() => jest.fn()); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +jest.mock('../../../store/actions', () => ({ + handleSnapRequest: jest.fn(), + sendMultichainTransaction: jest.fn(), + setDefaultHomeActiveTabName: jest.fn(), +})); + const PORTOFOLIO_URL = 'https://portfolio.test'; const BTC_OVERVIEW_BUY = 'coin-overview-buy'; const BTC_OVERVIEW_BRIDGE = 'coin-overview-bridge'; const BTC_OVERVIEW_RECEIVE = 'coin-overview-receive'; const BTC_OVERVIEW_SWAP = 'token-overview-button-swap'; +const BTC_OVERVIEW_SEND = 'coin-overview-send'; const BTC_OVERVIEW_PRIMARY_CURRENCY = 'coin-overview__primary-currency'; const mockMetaMetricsId = 'deadbeef'; @@ -228,6 +247,39 @@ describe('BtcOverview', () => { }); }); + it('sends an event when clicking the Buy button', () => { + const storeWithBtcBuyable = getStore({ + ramps: { + buyableChains: mockBuyableChainsWithBtc, + }, + }); + + const mockTrackEvent = jest.fn(); + const { queryByTestId } = renderWithProvider( + + + , + storeWithBtcBuyable, + ); + + const buyButton = queryByTestId(BTC_OVERVIEW_BUY); + expect(buyButton).toBeInTheDocument(); + expect(buyButton).not.toBeDisabled(); + fireEvent.click(buyButton as HTMLElement); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: MetaMetricsEventName.NavBuyButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockNonEvmAccount.type, + chain_id: MultichainNetworks.BITCOIN, + location: 'Home', + snap_id: mockNonEvmAccount.metadata.snap.id, + text: 'Buy', + }, + }); + }); + it('always show the Receive button', () => { const { queryByTestId } = renderWithProvider(, getStore()); const receiveButton = queryByTestId(BTC_OVERVIEW_RECEIVE); @@ -263,4 +315,42 @@ describe('BtcOverview', () => { expect(buyButton).toBeInTheDocument(); expect(buyButton).toBeDisabled(); }); + + it('always show the Send button', () => { + const { queryByTestId } = renderWithProvider(, getStore()); + const sendButton = queryByTestId(BTC_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + }); + + it('sends an event when clicking the Send button', () => { + const mockTrackEvent = jest.fn(); + const { queryByTestId } = renderWithProvider( + + + , + getStore(), + ); + + const sendButton = queryByTestId(BTC_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + fireEvent.click(sendButton as HTMLElement); + + expect(mockTrackEvent).toHaveBeenCalledWith( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockNonEvmAccount.type, + chain_id: MultichainNetworks.BITCOIN, + location: 'Home', + snap_id: mockNonEvmAccount.metadata.snap.id, + text: 'Send', + token_symbol: 'BTC', + }, + }, + expect.any(Object), + ); + }); }); diff --git a/ui/components/app/wallet-overview/btc-overview.tsx b/ui/components/app/wallet-overview/btc-overview.tsx index 2ddaefd92f58..fb315d3ab3b0 100644 --- a/ui/components/app/wallet-overview/btc-overview.tsx +++ b/ui/components/app/wallet-overview/btc-overview.tsx @@ -9,9 +9,9 @@ import { } from '../../../selectors/multichain'; ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) import { getIsBitcoinBuyable } from '../../../ducks/ramps'; -import { getSelectedInternalAccount } from '../../../selectors'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; ///: END:ONLY_INCLUDE_IF +import { getSelectedInternalAccount } from '../../../selectors'; import { CoinOverview } from './coin-overview'; type BtcOverviewProps = { @@ -21,17 +21,18 @@ type BtcOverviewProps = { const BtcOverview = ({ className }: BtcOverviewProps) => { const { chainId } = useSelector(getMultichainProviderConfig); const balance = useSelector(getMultichainSelectedAccountCachedBalance); + const account = useSelector(getSelectedInternalAccount); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) - const selectedAccount = useSelector(getSelectedInternalAccount); const isBtcMainnetAccount = useMultichainSelector( getMultichainIsMainnet, - selectedAccount, + account, ); const isBtcBuyable = useSelector(getIsBitcoinBuyable); ///: END:ONLY_INCLUDE_IF return ( { +}: CoinButtonsProps) => { const t = useContext(I18nContext); const dispatch = useDispatch(); const trackEvent = useContext(MetaMetricsContext); const [showReceiveModal, setShowReceiveModal] = useState(false); - const account = useSelector(getSelectedAccount); const { address: selectedAddress } = account; const history = useHistory(); ///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask) @@ -131,6 +138,16 @@ const CoinButtons = ({ const usingHardwareWallet = isHardwareKeyring(keyring?.type); ///: END:ONLY_INCLUDE_IF + // Initially, those events were using a "ETH" as `token_symbol`, so we keep this behavior + // for EVM, no matter the currently selected native token (e.g. SepoliaETH if you are on Sepolia + // network). + const isEvm = useMultichainSelector(getMultichainIsEvm, account); + const multichainNativeToken = useMultichainSelector( + getMultichainNativeCurrency, + account, + ); + const nativeToken = isEvm ? 'ETH' : multichainNativeToken; + const isExternalServicesEnabled = useSelector(getUseExternalServices); const buttonTooltips = { @@ -180,6 +197,23 @@ const CoinButtons = ({ }; ///: END:ONLY_INCLUDE_IF + const getSnapAccountMetaMetricsPropertiesIfAny = ( + internalAccount: InternalAccount, + ): { snap_id?: string } => { + // Some accounts might be Snap accounts, in this case we add some extra properties + // to the metrics: + const snapId = internalAccount.metadata.snap?.id; + if (snapId) { + return { + snap_id: snapId, + }; + } + + // If the account is not a Snap account or that we could not get the Snap ID for + // some reason, we don't add any extra property. + return {}; + }; + ///: BEGIN:ONLY_INCLUDE_IF(build-mmi) const mmiPortfolioEnabled = useSelector(getMmiPortfolioEnabled); const mmiPortfolioUrl = useSelector(getMmiPortfolioUrl); @@ -276,6 +310,21 @@ const CoinButtons = ({ ///: END:ONLY_INCLUDE_IF const handleSendOnClick = useCallback(async () => { + trackEvent( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: account.type, + token_symbol: nativeToken, + location: 'Home', + text: 'Send', + chain_id: chainId, + ...getSnapAccountMetaMetricsPropertiesIfAny(account), + }, + }, + { excludeMetaMetricsId: false }, + ); switch (account.type) { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) case BtcAccountType.P2wpkh: { @@ -291,19 +340,6 @@ const CoinButtons = ({ } ///: END:ONLY_INCLUDE_IF default: { - trackEvent( - { - event: MetaMetricsEventName.NavSendButtonClicked, - category: MetaMetricsEventCategory.Navigation, - properties: { - token_symbol: 'ETH', - location: 'Home', - text: 'Send', - chain_id: chainId, - }, - }, - { excludeMetaMetricsId: false }, - ); await dispatch(startNewDraftTransaction({ type: AssetType.native })); history.push(SEND_ROUTE); } @@ -358,10 +394,12 @@ const CoinButtons = ({ event: MetaMetricsEventName.NavBuyButtonClicked, category: MetaMetricsEventCategory.Navigation, properties: { + account_type: account.type, location: 'Home', text: 'Buy', chain_id: chainId, token_symbol: defaultSwapsToken, + ...getSnapAccountMetaMetricsPropertiesIfAny(account), }, }); }, [chainId, defaultSwapsToken]); diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx index 93d9e1061428..2244f3b33e82 100644 --- a/ui/components/app/wallet-overview/coin-overview.tsx +++ b/ui/components/app/wallet-overview/coin-overview.tsx @@ -11,6 +11,7 @@ import { zeroAddress } from 'ethereumjs-util'; import { CaipChainId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; +import { InternalAccount } from '@metamask/keyring-api'; import { Box, ButtonIcon, @@ -45,7 +46,6 @@ import UserPreferencedCurrencyDisplay from '../user-preferenced-currency-display import { PRIMARY } from '../../../helpers/constants/common'; import { getPreferences, - getSelectedAccount, getShouldHideZeroBalanceTokens, getTokensMarketData, getIsTestnet, @@ -74,6 +74,7 @@ import CoinButtons from './coin-buttons'; import { AggregatedPercentageOverview } from './aggregated-percentage-overview'; export type CoinOverviewProps = { + account: InternalAccount; balance: string; balanceIsCached: boolean; className?: string; @@ -90,6 +91,7 @@ export type CoinOverviewProps = { }; export const CoinOverview = ({ + account, balance, balanceIsCached, className, @@ -121,7 +123,6 @@ export const CoinOverview = ({ ///: END:ONLY_INCLUDE_IF - const account = useSelector(getSelectedAccount); const showNativeTokenAsMainBalanceRoute = getSpecificSettingsRoute( t, t('general'), @@ -135,12 +136,11 @@ export const CoinOverview = ({ const { showFiatInTestnets, privacyMode, showNativeTokenAsMainBalance } = useSelector(getPreferences); - const selectedAccount = useSelector(getSelectedAccount); const shouldHideZeroBalanceTokens = useSelector( getShouldHideZeroBalanceTokens, ); const { totalFiatBalance, loading } = useAccountTotalFiatBalance( - selectedAccount, + account, shouldHideZeroBalanceTokens, ); @@ -368,6 +368,7 @@ export const CoinOverview = ({ buttons={ { return ( jest.fn()); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + jest.mock('../../../hooks/useIsOriginalNativeTokenSymbol', () => { return { useIsOriginalNativeTokenSymbol: jest.fn(), @@ -24,6 +36,10 @@ jest.mock('../../../ducks/locale/locale', () => ({ getIntlLocale: jest.fn(), })); +jest.mock('../../../store/actions', () => ({ + startNewDraftTransaction: jest.fn(), +})); + const mockGetIntlLocale = getIntlLocale; let openTabSpy; @@ -32,18 +48,46 @@ describe('EthOverview', () => { useIsOriginalNativeTokenSymbol.mockReturnValue(true); mockGetIntlLocale.mockReturnValue('en-US'); + const mockEvmAccount1 = { + address: '0x1', + id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + metadata: { + name: 'Account 1', + keyring: { + type: KeyringType.imported, + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }; + + const mockEvmAccount2 = { + address: '0x2', + id: 'e9b992f9-e151-4317-b8b7-c771bb73dd02', + metadata: { + name: 'Account 2', + keyring: { + type: KeyringType.imported, + }, + }, + options: {}, + methods: ETH_EOA_METHODS, + type: EthAccountType.Eoa, + }; + const mockStore = { metamask: { ...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }), accountsByChainId: { [CHAIN_IDS.MAINNET]: { - '0x1': { address: '0x1', balance: '0x1F4' }, + '0x1': { address: mockEvmAccount1.address, balance: '0x1F4' }, }, }, tokenList: [], cachedBalances: { '0x1': { - '0x1': '0x1F4', + [mockEvmAccount1.address]: '0x1F4', }, }, preferences: { @@ -58,46 +102,22 @@ describe('EthOverview', () => { }, }, accounts: { - '0x1': { - address: '0x1', + [mockEvmAccount1.address]: { + address: mockEvmAccount1.address, balance: '0x1F4', }, }, internalAccounts: { accounts: { - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3': { - address: '0x1', - id: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', - metadata: { - name: 'Account 1', - keyring: { - type: KeyringType.imported, - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, - 'e9b992f9-e151-4317-b8b7-c771bb73dd02': { - address: '0x2', - id: 'e9b992f9-e151-4317-b8b7-c771bb73dd02', - metadata: { - name: 'Account 2', - keyring: { - type: KeyringType.imported, - }, - }, - options: {}, - methods: ETH_EOA_METHODS, - type: EthAccountType.Eoa, - }, + [mockEvmAccount1.id]: mockEvmAccount1, + [mockEvmAccount2.id]: mockEvmAccount2, }, - selectedAccount: 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3', + selectedAccount: mockEvmAccount1.id, }, keyrings: [ { type: KeyringType.imported, - accounts: ['0x1', '0x2'], + accounts: [mockEvmAccount1.address, mockEvmAccount2.address], }, { type: KeyringType.ledger, @@ -381,6 +401,36 @@ describe('EthOverview', () => { }); }); + it('sends an event when clicking the Buy button: %s', () => { + const mockTrackEvent = jest.fn(); + + const mockedStore = configureMockStore([thunk])(mockStore); + const { queryByTestId } = renderWithProvider( + + + , + mockedStore, + ); + + const buyButton = queryByTestId(ETH_OVERVIEW_BUY); + expect(buyButton).toBeInTheDocument(); + expect(buyButton).not.toBeDisabled(); + fireEvent.click(buyButton); + + expect(mockTrackEvent).toHaveBeenCalledWith({ + event: MetaMetricsEventName.NavBuyButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockEvmAccount1.type, + chain_id: CHAIN_IDS.MAINNET, + location: 'Home', + text: 'Buy', + // We use a `SwapsEthToken` in this case, so we're expecting an entire object here. + token_symbol: expect.any(Object), + }, + }); + }); + describe('Disabled buttons when an account cannot sign transactions', () => { const buttonTestCases = [ { testId: ETH_OVERVIEW_SEND, buttonText: 'Send' }, @@ -391,15 +441,30 @@ describe('EthOverview', () => { it.each(buttonTestCases)( 'should have the $buttonText button disabled when an account cannot sign transactions or user operations', ({ testId, buttonText }) => { - mockStore.metamask.internalAccounts.accounts[ - 'cf8dace4-9439-4bd4-b3a8-88c821c8fcb3' - ].methods = Object.values(EthMethod).filter( - (method) => - method !== EthMethod.SignTransaction && - method !== EthMethod.SignUserOperation, - ); + const mockedStoreWithoutSigningMethods = { + ...mockStore, + metamask: { + ...mockStore.metamask, + internalAccounts: { + ...mockStore.metamask.internalAccounts, + accounts: { + [mockEvmAccount1.id]: { + ...mockEvmAccount1, + // Filter out all methods used for signing transactions. + methods: Object.values(EthMethod).filter( + (method) => + method !== EthMethod.SignTransaction && + method !== EthMethod.SignUserOperation, + ), + }, + }, + }, + }, + }; - const mockedStore = configureMockStore([thunk])(mockStore); + const mockedStore = configureMockStore([thunk])( + mockedStoreWithoutSigningMethods, + ); const { queryByTestId, queryByText } = renderWithProvider( , mockedStore, @@ -415,4 +480,50 @@ describe('EthOverview', () => { }, ); }); + + it.each([ + CHAIN_IDS.MAINNET, + // We want to test with a different chain ID than mainnet to make sure the events are still using + // the right `token_symbol`. + CHAIN_IDS.SEPOLIA, + ])('sends an event when clicking the Send button: %s', (chainId) => { + const mockTrackEvent = jest.fn(); + const mockedStoreWithSpecificChainId = { + ...mockStore, + metamask: { + ...mockStore.metamask, + ...mockNetworkState({ chainId }), + }, + }; + + const mockedStore = configureMockStore([thunk])( + mockedStoreWithSpecificChainId, + ); + const { queryByTestId } = renderWithProvider( + + + , + mockedStore, + ); + + const sendButton = queryByTestId(ETH_OVERVIEW_SEND); + expect(sendButton).toBeInTheDocument(); + expect(sendButton).not.toBeDisabled(); + fireEvent.click(sendButton); + + expect(mockTrackEvent).toHaveBeenCalledWith( + { + event: MetaMetricsEventName.NavSendButtonClicked, + category: MetaMetricsEventCategory.Navigation, + properties: { + account_type: mockEvmAccount1.type, + chain_id: chainId, + location: 'Home', + text: 'Send', + token_symbol: 'ETH', + }, + }, + expect.any(Object), + ); + }); }); diff --git a/ui/pages/asset/components/asset-page.tsx b/ui/pages/asset/components/asset-page.tsx index c70b60169edb..0d73188d145e 100644 --- a/ui/pages/asset/components/asset-page.tsx +++ b/ui/pages/asset/components/asset-page.tsx @@ -166,6 +166,7 @@ const AssetPage = ({ {type === AssetType.native ? (