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 ? (