diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx index 948a508e4f5..6df9e6929b4 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.test.tsx @@ -41,6 +41,11 @@ jest.mock('../../hooks/usePerpsMeasurement', () => ({ usePerpsMeasurement: jest.fn(), })); +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + jest.mock('../../../../../util/Logger', () => ({ __esModule: true, default: { diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx index 65b1c303514..4b911ad5ac0 100644 --- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx +++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx @@ -13,7 +13,13 @@ import { ButtonSize, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; -import { type Position, PERPS_CONSTANTS } from '@metamask/perps-controller'; +import { + type Position, + PERPS_CONSTANTS, + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; import styleSheet from './PerpsAdjustMarginView.styles'; import { useTheme } from '../../../../../util/theme'; import Icon, { @@ -26,6 +32,7 @@ import ButtonIcon, { } from '../../../../../component-library/components/Buttons/ButtonIcon'; import { PerpsAdjustMarginViewSelectorsIDs } from '../../Perps.testIds'; import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsAdjustMarginData } from '../../hooks/usePerpsAdjustMarginData'; import { TraceName } from '../../../../../util/trace'; @@ -119,6 +126,17 @@ const PerpsAdjustMarginView: React.FC = () => { debugContext: { mode }, }); + usePerpsEventTracking({ + eventName: MetaMetricsEvents.PERPS_SCREEN_VIEWED, + resetKey: mode, + properties: { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: isAddMode + ? PERPS_EVENT_VALUE.SCREEN_TYPE.ADD_MARGIN + : PERPS_EVENT_VALUE.SCREEN_TYPE.REMOVE_MARGIN, + [PERPS_EVENT_PROPERTY.ASSET]: routePosition?.symbol, + }, + }); + const handleSliderChange = useCallback((value: number) => { // Floor to 2 decimal places to match Hyperliquid behavior const flooredValue = Math.floor(value * 100) / 100; diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index 5dde1e95e07..9e830389371 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -1,5 +1,10 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; -import { ORDER_SLIPPAGE_CONFIG } from '@metamask/perps-controller'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, + ORDER_SLIPPAGE_CONFIG, +} from '@metamask/perps-controller'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { @@ -151,11 +156,11 @@ jest.mock('../../components/PerpsBottomSheetTooltip', () => ({ jest.mock('../../../Rewards/components/RewardsVipBadge/RewardsVipBadge', () => { const MockReact = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); + const { View: MockView } = jest.requireActual('react-native'); return { __esModule: true, default: () => - MockReact.createElement(View, { testID: 'rewards-vip-badge' }), + MockReact.createElement(MockView, { testID: 'rewards-vip-badge' }), }; }); @@ -237,6 +242,7 @@ describe('PerpsClosePositionView', () => { // Setup navigation mocks useNavigationMock.mockReturnValue({ goBack: mockGoBack, + addListener: jest.fn(() => jest.fn()), }); // Setup default route params @@ -1992,31 +1998,33 @@ describe('PerpsClosePositionView', () => { // HyperLiquid's marginUsed already includes PnL // receivedAmount = marginUsed - fees = 1450 - 45 = 1405 // realizedPnl = unrealizedPnl = 150 (from defaultPerpsPositionMock) - expect(handleClosePosition).toHaveBeenCalledWith({ - position: defaultPerpsPositionMock, - size: '', - orderType: 'market', - limitPrice: undefined, - trackingData: { - totalFee: 45, - marketPrice: 3000, - receivedAmount: 1405, - realizedPnl: 150, - metamaskFeeRate: 0, - metamaskFee: 0, - feeDiscountPercentage: undefined, - estimatedPoints: undefined, - inputMethod: 'default', - }, - marketPrice: '3000.00', - // Slippage parameters added in USD-as-source-of-truth refactor - // For full closes (100%), usdAmount is undefined to bypass $10 minimum validation - slippage: { - usdAmount: undefined, // undefined for full close to bypass $10 minimum validation - priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders - maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, - }, - }); + expect(handleClosePosition).toHaveBeenCalledWith( + expect.objectContaining({ + position: defaultPerpsPositionMock, + size: '', + orderType: 'market', + limitPrice: undefined, + trackingData: expect.objectContaining({ + totalFee: 45, + marketPrice: 3000, + receivedAmount: 1405, + realizedPnl: 150, + metamaskFeeRate: 0, + metamaskFee: 0, + feeDiscountPercentage: undefined, + estimatedPoints: undefined, + inputMethod: 'default', + }), + marketPrice: '3000.00', + // Slippage parameters added in USD-as-source-of-truth refactor + // For full closes (100%), usdAmount is undefined to bypass $10 minimum validation + slippage: { + usdAmount: undefined, // undefined for full close to bypass $10 minimum validation + priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders + maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, + }, + }), + ); }); }); @@ -3049,6 +3057,163 @@ describe('PerpsClosePositionView', () => { }); }); + describe('abandon order tracking', () => { + const abandonInteraction = [ + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.ACTION]: PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER, + }), + ]; + + let focusSpy: jest.SpyInstance; + + const setupAbandonNav = (routes: { key: string }[]) => { + const listeners: Record void)[]> = {}; + useNavigationMock.mockReturnValue({ + goBack: mockGoBack, + navigate: jest.fn(), + addListener: jest.fn((event: string, cb: () => void) => { + (listeners[event] = listeners[event] || []).push(cb); + return jest.fn(); + }), + getState: jest.fn(() => ({ routes })), + getParent: jest.fn(() => undefined), + }); + return (event: string) => (listeners[event] || []).forEach((cb) => cb()); + }; + + beforeEach(() => { + // Run the focus callback so the abandon hook records the focus depth. + focusSpy = jest + .spyOn(jest.requireMock('@react-navigation/native'), 'useFocusEffect') + .mockImplementation((...args: unknown[]) => (args[0] as () => void)()); + }); + + afterEach(() => { + focusSpy.mockRestore(); + }); + + it('emits abandon_order on beforeRemove (back / hardware back)', () => { + const fire = setupAbandonNav([{ key: 'close' }]); + renderWithProvider(); + + act(() => fire('beforeRemove')); + + expect(defaultPerpsEventTrackingMock.track).toHaveBeenCalledWith( + ...abandonInteraction, + ); + }); + + it('emits abandon_order on tab-away (blur with unchanged depth)', () => { + const fire = setupAbandonNav([{ key: 'close' }]); + renderWithProvider(); + + act(() => fire('blur')); + + expect(defaultPerpsEventTrackingMock.track).toHaveBeenCalledWith( + ...abandonInteraction, + ); + }); + + it('does NOT emit on blur when a child route was pushed (depth increased)', () => { + const routes = [{ key: 'close' }]; + const fire = setupAbandonNav(routes); + renderWithProvider(); + + routes.push({ key: 'child' }); + act(() => fire('blur')); + + expect(defaultPerpsEventTrackingMock.track).not.toHaveBeenCalledWith( + ...abandonInteraction, + ); + }); + + it('does NOT emit after a confirmed close', () => { + const fire = setupAbandonNav([{ key: 'close' }]); + const { getByTestId } = renderWithProvider(); + + fireEvent.press( + getByTestId( + PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, + ), + ); + act(() => fire('beforeRemove')); + + expect(defaultPerpsEventTrackingMock.track).not.toHaveBeenCalledWith( + ...abandonInteraction, + ); + }); + }); + + describe('position_close entry action (button_clicked)', () => { + const expectScreenViewed = (expected: Record) => + expect(defaultPerpsEventTrackingMock.track).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: + PERPS_EVENT_VALUE.SCREEN_TYPE.POSITION_CLOSE, + ...expected, + }), + ); + + it('reports button_clicked=reduce_exposure and source=position_screen when opened via the reduce-exposure entry', () => { + useRouteMock.mockReturnValue({ + params: { + position: defaultPerpsPositionMock, + source: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, + }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE, + [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: + PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, + }); + }); + + it('reports button_clicked=close and source=order_book when opened via the order-book close entry', () => { + useRouteMock.mockReturnValue({ + params: { + position: defaultPerpsPositionMock, + source: PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK, + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK, + }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: + PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK, + }); + }); + + it('defaults button_clicked=close and source=perp_asset_screen when no entry action is provided', () => { + useRouteMock.mockReturnValue({ + params: { position: defaultPerpsPositionMock }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + }); + }); + }); + describe('Order type selector (feature flag)', () => { const selectPerpsClosePositionLimitOrderEnabledFlagMock = jest.mocked( jest.requireMock('../../selectors/featureFlags') diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index 63946c760b4..f140de7b2d1 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -58,12 +58,14 @@ import { usePerpsTopOfBook, } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { usePerpsAbandonOrderTracking } from '../../hooks/usePerpsAbandonOrderTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { formatPositionSize, formatPerpsFiat, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { calculateCloseAmountFromPercentage, validateCloseAmountLimits, @@ -87,13 +89,22 @@ const PerpsClosePositionView: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); - const { position, source: routeSource } = route.params as { + const { + position, + source: routeSource, + buttonClicked: entryButtonClicked, + buttonLocation: entryButtonLocation, + } = route.params as { position: Position; source?: string; + buttonClicked?: string; + buttonLocation?: string; }; const inputMethodRef = useRef('default'); const isAmountInitializedRef = useRef(false); + const hasConfirmedCloseRef = useRef(false); + const latestAbandonPropsRef = useRef>({}); const { showToast, PerpsToastOptions } = usePerpsToasts(); @@ -381,11 +392,48 @@ const PerpsClosePositionView: React.FC = () => { [PERPS_EVENT_PROPERTY.POSITION_SIZE]: absSize, [PERPS_EVENT_PROPERTY.UNREALIZED_PNL_DOLLAR]: pnl, [PERPS_EVENT_PROPERTY.UNREALIZED_PNL_PERCENT]: unrealizedPnlPercent, - [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + // Honour the route-provided source threaded by each entry CTA + // (reduce-exposure → position_screen, order-book → order_book); fall back + // to the asset screen for direct entries that pass no source. + [PERPS_EVENT_PROPERTY.SOURCE]: + routeSource ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: receiveAmount, + // The entry CTA (close vs reduce_exposure) is passed via the navigation + // route param — closePercentage defaults to 100 at open, so isPartialClose + // can't identify which CTA opened this screen. isPartialClose still drives + // later interaction events, just not this entry screen-view. + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + entryButtonClicked ?? PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: + entryButtonLocation ?? PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, }, }); + latestAbandonPropsRef.current = { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + [PERPS_EVENT_PROPERTY.ACTION]: PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER, + [PERPS_EVENT_PROPERTY.ASSET]: position.symbol, + [PERPS_EVENT_PROPERTY.DIRECTION]: isLong + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: closingValue, + [PERPS_EVENT_PROPERTY.LEVERAGE_USED]: livePosition.leverage?.value, + }; + + // emit abandon_order on a real exit (back swipe, hardware back, + // programmatic dismissal) AND on a genuine tab switch away, but never when a + // child route (e.g. the limit-price flow) is pushed or after a confirmed close + // (hasConfirmedCloseRef). + const getAbandonProperties = useCallback( + () => latestAbandonPropsRef.current, + [], + ); + usePerpsAbandonOrderTracking({ + getAbandonProperties, + hasCommittedRef: hasConfirmedCloseRef, + }); + // Initialize USD values when price data is available (only once, not on price updates) useEffect(() => { if (!isAmountInitializedRef.current && absSize > 0 && effectivePrice > 0) { @@ -420,6 +468,9 @@ const PerpsClosePositionView: React.FC = () => { return; } + // Mark confirmed so the focus-effect cleanup does not emit an abandon event + hasConfirmedCloseRef.current = true; + // Go back immediately to close the position screen navigation.goBack(); @@ -439,6 +490,10 @@ const PerpsClosePositionView: React.FC = () => { estimatedPoints: rewardsState.estimatedPoints, inputMethod: inputMethodRef.current, source: routeSource, + ...toPerpsEntryAttribution({ source: routeSource }), + ...(feeResults.protocolFeeRate !== undefined + ? { hlFeeRate: feeResults.protocolFeeRate } + : {}), vipTier: vipTier ?? undefined, vipDiscount: feeResults.feeDiscountPercentage, }, diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 0dac10f3ad6..83d58e7168d 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -224,6 +224,7 @@ const mockRouteParams: { asset: string; monitor: 'orders' | 'positions' | 'both'; }; + source_section?: string; transactionActiveAbTests?: { key: string; value: string; @@ -921,6 +922,7 @@ describe('PerpsMarketDetailsView', () => { maxLeverage: '40x', }; mockRouteParams.transactionActiveAbTests = undefined; + mockRouteParams.source_section = undefined; // Reset order fills mock to default mockUsePerpsLiveFillsImpl.mockReturnValue({ @@ -2640,6 +2642,49 @@ describe('PerpsMarketDetailsView', () => { } }); + it('forwards route source_section into navigateToOrder params', async () => { + const { useSelector } = jest.requireMock('react-redux'); + const mockSelectPerpsEligibility = jest.requireMock( + '../../selectors/perpsController', + ).selectPerpsEligibility; + useSelector.mockImplementation((selector: unknown) => { + if (selector === mockSelectPerpsEligibility) { + return true; + } + return undefined; + }); + mockRouteParams.source_section = 'watchlist'; + + try { + const { getByTestId } = renderWithProvider( + + + , + { + state: initialState, + }, + ); + + const longButton = getByTestId( + PerpsMarketDetailsViewSelectorsIDs.LONG_BUTTON, + ); + await act(async () => { + fireEvent.press(longButton); + }); + + expect(mockNavigateToOrder).toHaveBeenCalledWith( + expect.objectContaining({ + direction: 'long', + asset: 'BTC', + source: 'perp_asset_screen', + source_section: 'watchlist', + }), + ); + } finally { + mockRouteParams.source_section = undefined; + } + }); + it('navigates to short order screen when short button is pressed and user is eligible', async () => { const { useSelector } = jest.requireMock('react-redux'); const mockSelectPerpsEligibility = jest.requireMock( diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index b56a8d92c74..0032c0bbb40 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -150,6 +150,7 @@ import { PERPS_BUTTON_COLOR_AB_TEST_KEY, } from '../../abTestConfig'; import { getMarketHoursStatus } from '../../utils/marketHours'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { normalizeMarketDetailsOrders } from '../../normalization/normalizeMarketDetailsOrders'; import { ensureError } from '../../../../../util/errorUtils'; import { @@ -697,6 +698,7 @@ const PerpsMarketDetailsView: React.FC = () => { }), [PERPS_EVENT_PROPERTY.OPEN_POSITION]: existingPosition ? 1 : 0, [PERPS_EVENT_PROPERTY.OPEN_ORDER]: openOrders.length, + [PERPS_EVENT_PROPERTY.WATCHLISTED]: isWatchlist, market_insights_displayed: isPerpsInsightsEnabled && Boolean(perpsInsightsReport), [PERPS_EVENT_PROPERTY.OUTAGE_BANNER_SHOWN]: @@ -713,6 +715,7 @@ const PerpsMarketDetailsView: React.FC = () => { source_section, existingPosition, openOrders.length, + isWatchlist, isPerpsInsightsEnabled, perpsInsightsReport, isServiceInterruptionBannerEnabled, @@ -923,6 +926,7 @@ const PerpsMarketDetailsView: React.FC = () => { direction, asset: market.symbol, source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ...(source_section ? { source_section } : {}), chartLibrary, defaultSzDecimals: marketData?.szDecimals, defaultMaxLeverage: marketData?.maxLeverage, @@ -938,6 +942,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigation, track, navigateToOrder, + source_section, transactionActiveAbTests, market?.symbol, marketData, @@ -1104,6 +1109,10 @@ const PerpsMarketDetailsView: React.FC = () => { navigateToClosePosition( existingPosition, PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + { + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.ASSET_DETAILS, + }, ); }); }, [gate, existingPosition, navigateToClosePosition, isEligible, track]); @@ -1187,10 +1196,12 @@ const PerpsMarketDetailsView: React.FC = () => { try { // Build tracking data + const riskSource = + PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.STOP_LOSS_PROMPT_BANNER; const trackingData: TPSLTrackingData = { direction: parseFloat(existingPosition.size) >= 0 ? 'long' : 'short', - source: - PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.STOP_LOSS_PROMPT_BANNER, + source: riskSource, + ...toPerpsEntryAttribution({ source: riskSource }), positionSize: Math.abs(parseFloat(existingPosition.size)), }; diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 4dc255ccb56..f01225b4e92 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, fireEvent, waitFor } from '@testing-library/react-native'; +import { screen, fireEvent, waitFor, act } from '@testing-library/react-native'; import { NavigationProp, ParamListBase, @@ -13,6 +13,7 @@ import { import { PerpsMarketListViewSelectorsIDs } from '../../Perps.testIds'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { createActiveABTestAssignment } from '../../../../../util/analytics/activeABTestAssignments'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -442,10 +443,16 @@ jest.mock('../../../../Views/confirmations/hooks/useConfirmNavigation', () => ({ })), })); +// Stable references so useSelector doesn't warn about a selector returning a +// new array/object identity on every call when nothing has actually changed. +const mockEmptyWatchlistMarkets: string[] = []; +const mockEmptyRecentlyViewedMarkets: string[] = []; jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(() => true), - selectPerpsWatchlistMarkets: jest.fn(() => []), - selectPerpsRecentlyViewedMarkets: jest.fn(() => []), + selectPerpsWatchlistMarkets: jest.fn(() => mockEmptyWatchlistMarkets), + selectPerpsRecentlyViewedMarkets: jest.fn( + () => mockEmptyRecentlyViewedMarkets, + ), selectPerpsMarketFilterPreferences: jest.fn(() => ({ optionId: 'volume', direction: 'desc', @@ -752,6 +759,9 @@ describe('PerpsMarketListView', () => { if (originalConsoleError) { console.error = originalConsoleError; } + // Safety net: prevent fake-timer leaks across tests if a fake-timer + // test fails before reaching its own jest.useRealTimers() call. + jest.useRealTimers(); }); describe('Component Rendering', () => { @@ -1895,6 +1905,23 @@ describe('PerpsMarketListView', () => { ); }); + it('fires filter_applied with filter_category=watchlist when the watchlist toggle is pressed', () => { + mockWatchlistFlagEnabled = true; + renderWithProvider(, { state: mockState }); + + fireEvent.press( + screen.getByTestId(`${FILTERS_TEST_ID}-categories-watchlist`), + ); + + expect(mockTrack).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + [PEP.INTERACTION_TYPE]: PEV.INTERACTION_TYPE.FILTER_APPLIED, + [PEP.FILTER_CATEGORY]: PEV.BUTTON_CLICKED.WATCHLIST, + }), + ); + }); + it('includes source_section=all_markets when pressing a market with no active filter or search', () => { renderWithProvider(, { state: mockState }); @@ -1958,21 +1985,554 @@ describe('PerpsMarketListView', () => { renderWithProvider(, { state: mockState }); - // Advance past the 600ms debounce window - jest.advanceTimersByTime(700); + // Advance past the 500ms debounce window + jest.advanceTimersByTime(600); await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith( - expect.anything(), + MetaMetricsEvents.PERPS_SEARCH_QUERY, expect.objectContaining({ - [PEP.INTERACTION_TYPE]: PEV.INTERACTION_TYPE.SEARCH_CLICKED, + [PEP.SEARCH_QUERY]: 'bt', + query_text: 'bt', + query_length: 2, + [PEP.RESULTS_COUNT]: 1, [PEP.RESULT_COUNT]: 1, + has_results: true, + [PEP.MODE]: 'intent', + [PEP.SOURCE]: PEV.SOURCE.PERP_MARKET_SEARCH, }), ); }); + // a results-shown screen view accompanies the search query event. + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + expect.objectContaining({ + [PEP.SCREEN_TYPE]: PEV.SCREEN_TYPE.SEARCH_RESULTS_SHOWN, + [PEP.SEARCH_QUERY]: 'bt', + [PEP.RESULT_COUNT]: 1, + }), + ); + + jest.useRealTimers(); + }); + + it('fires a search_no_results screen view when the query returns nothing', async () => { + jest.useFakeTimers(); + + mockUsePerpsMarketListView.mockReturnValue({ + markets: [], + searchState: { + searchQuery: 'ZZZZ', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 0, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + jest.advanceTimersByTime(600); + + await waitFor(() => { + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_QUERY, + expect.objectContaining({ + [PEP.SEARCH_QUERY]: 'zzzz', + [PEP.RESULT_COUNT]: 0, + has_results: false, + }), + ); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + expect.objectContaining({ + [PEP.SCREEN_TYPE]: PEV.SCREEN_TYPE.SEARCH_NO_RESULTS, + [PEP.SEARCH_QUERY]: 'zzzz', + [PEP.RESULT_COUNT]: 0, + }), + ); + + jest.useRealTimers(); + }); + + it('emits SEARCH_ABANDONED on blur for an emitted, un-tapped query', () => { + jest.useFakeTimers(); + + mockUsePerpsMarketListView.mockReturnValue({ + markets: [mockMarketData[0]], + searchState: { + searchQuery: 'BT', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 1, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // Let the debounce emit the query. + act(() => { + jest.advanceTimersByTime(600); + }); + + // Simulate the screen blurring by invoking every captured focus cleanup + // (the abandon cleanup is among them; sibling cleanups are no-ops here). + const rnav = jest.requireMock('@react-navigation/native'); + const focusCleanups = ( + rnav.useFocusEffect.mock.results as { value: unknown }[] + ) + .map((r) => r.value) + .filter((v): v is () => void => typeof v === 'function'); + act(() => { + focusCleanups.forEach((cleanup) => cleanup()); + }); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_ABANDONED, + expect.objectContaining({ + [PEP.SEARCH_QUERY]: 'bt', + query_count: 1, + }), + ); + + jest.useRealTimers(); + }); + + it('flushes a pending search query on blur while loading with the count props omitted', () => { + jest.useFakeTimers(); + + mockUsePerpsMarketListView.mockReturnValue({ + markets: [], + searchState: { + searchQuery: 'BT', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 0, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + // Results still loading — the flush must emit the query without a + // count, never drop it and never report a stale count. + isLoading: true, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // The debounce is gated while loading, so nothing is scheduled. + act(() => { + jest.advanceTimersByTime(600); + }); + + const rnav = jest.requireMock('@react-navigation/native'); + const focusCleanups = ( + rnav.useFocusEffect.mock.results as { value: unknown }[] + ) + .map((r) => r.value) + .filter((v): v is () => void => typeof v === 'function'); + act(() => { + focusCleanups.forEach((cleanup) => cleanup()); + }); + + // The pending query is still emitted (never a silent drop) but with the + // count-dependent props omitted, since the count is unknown mid-load. + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_QUERY, + expect.objectContaining({ + [PEP.SEARCH_QUERY]: 'bt', + query_text: 'bt', + query_length: 2, + }), + ); + const searchQueryProps = + mockTrack.mock.calls.find( + ([name]) => name === MetaMetricsEvents.PERPS_SEARCH_QUERY, + )?.[1] ?? {}; + expect(searchQueryProps).not.toHaveProperty(PEP.RESULTS_COUNT); + expect(searchQueryProps).not.toHaveProperty(PEP.RESULT_COUNT); + expect(searchQueryProps).not.toHaveProperty('has_results'); + // No results/no-results screen view is recorded while loading — the + // counts that determine which screen type was shown are unknown. + expect(mockTrack).not.toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + expect.objectContaining({ + [PEP.SCREEN_TYPE]: expect.stringMatching(/search/i), + }), + ); + + // The same blur also abandons the flushed-but-unsettled query. Its + // count was never settled, so results_count must be omitted (not + // recorded as 0 — that would misreport "no results" while still loading). + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_ABANDONED, + expect.objectContaining({ [PEP.SEARCH_QUERY]: 'bt' }), + ); + const searchAbandonedProps = + mockTrack.mock.calls.find( + ([name]) => name === MetaMetricsEvents.PERPS_SEARCH_ABANDONED, + )?.[1] ?? {}; + expect(searchAbandonedProps).not.toHaveProperty(PEP.RESULTS_COUNT); + + jest.useRealTimers(); + }); + + it('emits the pending SEARCH_QUERY before RESULT_TAPPED when a result is tapped mid-debounce', () => { + jest.useFakeTimers(); + + mockUsePerpsMarketListView.mockReturnValue({ + markets: [mockMarketData[0]], + searchState: { + searchQuery: 'BT', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 1, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // Tap a result BEFORE the 500ms debounce fires — the query is still + // pending. The tap must flush it first so the funnel is query → tap. + act(() => { + fireEvent.press(screen.getByTestId('market-row-BTC')); + }); + + const emitted = mockTrack.mock.calls.map(([name]) => name); + const queryIdx = emitted.indexOf(MetaMetricsEvents.PERPS_SEARCH_QUERY); + const tapIdx = emitted.indexOf( + MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, + ); + expect(queryIdx).toBeGreaterThanOrEqual(0); + expect(tapIdx).toBeGreaterThanOrEqual(0); + expect(queryIdx).toBeLessThan(tapIdx); + jest.useRealTimers(); }); + + it('reports accurate result_rank and results_count when a suggested watchlist result is tapped', () => { + mockWatchlistFlagEnabled = true; + + const watchlistMarket = mockMarketData[0]; // BTC — "Bitcoin" matches 't' + const suggestedMarket = mockMarketData[1]; // ETH — "Ethereum" matches 't' + + mockUsePerpsMarketListView.mockReturnValue({ + markets: [], + searchState: { + searchQuery: 't', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [watchlistMarket], + suggestedMarkets: [suggestedMarket], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 3, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + // The rendered collection is watchlist rows + suggested rows (both + // match the query), so the suggested row is the 2nd visible result — + // filteredMarkets (empty here) would have reported rank omitted / + // results_count 0 instead. + fireEvent.press( + screen.getByTestId(`suggested-row-${suggestedMarket.symbol}`), + ); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, + expect.objectContaining({ + [PEP.SEARCH_QUERY]: 't', + [PEP.RESULTS_COUNT]: 2, + [PEP.RESULT_RANK]: 2, + [PEP.ASSET]: suggestedMarket.symbol, + }), + ); + }); + + it('resets the full session on abandonment so query_count does not inflate across sessions', () => { + jest.useFakeTimers(); + + const sessionMock = (searchQuery: string) => ({ + markets: [mockMarketData[0]], + searchState: { + searchQuery, + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: false, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: false, + watchlistMarketObjects: [], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 1, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + const rnav = jest.requireMock('@react-navigation/native'); + const blur = () => + act(() => { + (rnav.useFocusEffect.mock.results as { value: unknown }[]) + .map((r) => r.value) + .filter((v): v is () => void => typeof v === 'function') + .forEach((cleanup) => cleanup()); + }); + + // Session 1: emit then abandon on blur (resets the session). + mockUsePerpsMarketListView.mockReturnValue(sessionMock('BT')); + const { rerender } = renderWithProvider(, { + state: mockState, + }); + act(() => jest.advanceTimersByTime(600)); + blur(); + + // Session 2: a fresh query — its abandonment must report query_count 1, + // not an inflated count carried over from session 1. + mockTrack.mockClear(); + mockUsePerpsMarketListView.mockReturnValue(sessionMock('ETH')); + act(() => rerender()); + act(() => jest.advanceTimersByTime(600)); + blur(); + + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_ABANDONED, + expect.objectContaining({ query_count: 1 }), + ); + + jest.useRealTimers(); + }); + + it('does NOT fire filter_applied when the watchlist toggle is turned OFF', () => { + mockWatchlistFlagEnabled = true; + // Watchlist already active → pressing it clears the filter. + mockUsePerpsMarketListView.mockReturnValue({ + markets: [mockMarketData[0]], + searchState: { + searchQuery: '', + setSearchQuery: mockSetSearchQuery, + clearSearch: mockClearSearch, + }, + sortState: { + selectedOptionId: 'volume', + sortBy: 'volume', + direction: 'desc' as const, + handleOptionChange: jest.fn(), + }, + favoritesState: { + showFavoritesOnly: true, + setShowFavoritesOnly: jest.fn(), + hasWatchlistMarkets: true, + watchlistMarketObjects: [mockMarketData[0]], + suggestedMarkets: [], + }, + marketTypeFilterState: { + marketTypeFilter: 'all' as const, + setMarketTypeFilter: jest.fn(), + }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, + marketCounts: { + crypto: 1, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + isLoading: false, + error: null, + }); + + renderWithProvider(, { state: mockState }); + + fireEvent.press( + screen.getByTestId(`${FILTERS_TEST_ID}-categories-watchlist`), + ); + + expect(mockTrack).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + [PEP.INTERACTION_TYPE]: PEV.INTERACTION_TYPE.FILTER_APPLIED, + }), + ); + }); }); describe('Recently Viewed Rail', () => { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 7097ca3d2ab..5dc7a71c3f5 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -53,6 +53,7 @@ import { useRoute, RouteProp, useNavigation, + useFocusEffect, StackActions, } from '@react-navigation/native'; import Routes from '../../../../../constants/navigation/Routes'; @@ -63,6 +64,8 @@ import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; import { PerpsNavigationParamList } from '../../types/navigation'; import { normalizeFilterKey } from '../../utils/marketCategoryMapping'; +import { WATCHLIST_LIMIT } from '../../utils/marketUtils'; +import { selectPerpsWatchlistMarkets } from '../../selectors/perpsController'; const PerpsMarketListView = ({ onMarketSelect, @@ -139,9 +142,92 @@ const PerpsMarketListView = ({ // Destructure market type filter state const { marketTypeFilter, setMarketTypeFilter } = marketTypeFilterState; + const { track } = usePerpsEventTracking(); + + // Search-session tracking refs — shared by the debounced query effect and the + // result-tap handler so time-to-tap / abandonment durations line up. + const searchResultTimerRef = useRef | null>( + null, + ); + const lastEmittedSearchQueryRef = useRef(''); + // undefined means the count was never settled (e.g. a blur/unmount flush + // while markets were still loading) — distinct from a settled 0 results. + const lastEmittedSearchResultsCountRef = useRef( + undefined, + ); + const searchStartTimeRef = useRef(null); + const searchQueryCountRef = useRef(0); + // query awaiting the debounce (typed but not yet emitted). Flushed + // on blur/unmount so a mid-debounce exit is never silently lost. + const pendingSearchQueryRef = useRef(null); + const flushedUnsettledSearchQueryRef = useRef(null); + // set when a search result is tapped so leaving the screen after a + // tap is not counted as a search abandonment. + const searchResultTappedRef = useRef(false); + // Holds the latest flushPendingSearchQuery so handleMarketPress (defined + // before it) can force the pending query to emit before the result-tap event, + // keeping the funnel order query → tap. Assigned once the callback exists. + const flushPendingSearchQueryRef = useRef<() => void>(() => { + // Assigned below once flushPendingSearchQuery is defined. + }); // Destructure recently viewed state const { recentlyViewedMarketObjects } = recentlyViewedState; + // Used to mirror PerpsWatchlistMarketsV2's suggested-section visibility gate + // (hidden once the watchlist is full) so analytics match what's rendered. + const watchlistSymbols = useSelector(selectPerpsWatchlistMarkets); + + const trimmedSearchQuery = searchQuery.trim().toLowerCase(); + + // Watchlist rows visible in watchlist mode, filtered by the active search + // query — mirrors the filtering PerpsWatchlistMarketsV2 applies to its + // `markets` prop when a query is active. + const visibleWatchlistMarkets = useMemo( + () => + trimmedSearchQuery + ? watchlistMarketObjects.filter( + (m) => + m.symbol.toLowerCase().includes(trimmedSearchQuery) || + m.name.toLowerCase().includes(trimmedSearchQuery), + ) + : watchlistMarketObjects, + [watchlistMarketObjects, trimmedSearchQuery], + ); + + // Suggested markets visible in watchlist mode, filtered by the active + // search query and hidden once the watchlist is full — mirrors + // PerpsWatchlistMarketsV2's `hasSuggested && !isWatchlistFull` gate. + const visibleSuggestedMarkets = useMemo(() => { + if (watchlistSymbols.length >= WATCHLIST_LIMIT) { + return []; + } + return trimmedSearchQuery + ? (suggestedMarkets?.filter( + (m) => + m.symbol.toLowerCase().includes(trimmedSearchQuery) || + m.name.toLowerCase().includes(trimmedSearchQuery), + ) ?? []) + : (suggestedMarkets ?? []); + }, [suggestedMarkets, trimmedSearchQuery, watchlistSymbols.length]); + + // The collection actually rendered to the user, used to derive search + // analytics (result rank / count) so tapping a suggested watchlist result + // reports accurate metrics instead of being computed against filteredMarkets, + // which excludes suggestions. + const visibleSearchResults = useMemo( + () => + isWatchlistEnabled && showFavoritesOnly + ? [...visibleWatchlistMarkets, ...visibleSuggestedMarkets] + : filteredMarkets, + [ + isWatchlistEnabled, + showFavoritesOnly, + visibleWatchlistMarkets, + visibleSuggestedMarkets, + filteredMarkets, + ], + ); + // Handler for market press (defined early to avoid use-before-define) const handleMarketPress = useCallback( (market: PerpsMarketData, sourceSectionOverride?: string) => { @@ -157,6 +243,29 @@ const PerpsMarketListView = ({ source_section = sourceSectionOverride; } else if (trimmedQuery) { source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.ACTIVE_SEARCH; + const resultRank = + visibleSearchResults.findIndex((m) => m.symbol === market.symbol) + + 1; + const timeToTapMs = + searchStartTimeRef.current !== null + ? Date.now() - searchStartTimeRef.current + : undefined; + // A fast tap can land before the still-debouncing PERPS_SEARCH_QUERY + // has fired. Flush it synchronously first so the funnel stream is + // always query → tap, never tap → query. + flushPendingSearchQueryRef.current(); + track(MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery.toLowerCase(), + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: visibleSearchResults.length, + ...(resultRank > 0 + ? { [PERPS_EVENT_PROPERTY.RESULT_RANK]: resultRank } + : {}), + ...(timeToTapMs !== undefined + ? { time_to_tap_ms: timeToTapMs } + : {}), + [PERPS_EVENT_PROPERTY.ASSET]: market.symbol, + }); + searchResultTappedRef.current = true; } else if (showFavoritesOnly) { source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.WATCHLIST; } else if (marketTypeFilter !== 'all') { @@ -192,11 +301,11 @@ const PerpsMarketListView = ({ searchQuery, showFavoritesOnly, marketTypeFilter, + visibleSearchResults, + track, ], ); - const { track } = usePerpsEventTracking(); - // Handle category badge selection — clears watchlist filter (mutual exclusivity) const handleCategorySelect = useCallback( (category: MarketTypeFilter) => { @@ -207,6 +316,11 @@ const PerpsMarketListView = ({ [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: PERPS_EVENT_VALUE.BUTTON_LOCATION.MARKET_LIST, }); + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FILTER_APPLIED, + [PERPS_EVENT_PROPERTY.FILTER_CATEGORY]: category, + }); setMarketTypeFilter(category); setShowFavoritesOnly(false); }, @@ -224,12 +338,37 @@ const PerpsMarketListView = ({ [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: PERPS_EVENT_VALUE.BUTTON_LOCATION.MARKET_LIST, }); + // The watchlist toggle is a filter control too, so emit filter_applied + // alongside category filters for a complete filter funnel — but only when + // APPLYING the filter, not when toggling it off (which clears the filter). + if (willActivate) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.FILTER_APPLIED, + [PERPS_EVENT_PROPERTY.FILTER_CATEGORY]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.WATCHLIST, + }); + } setShowFavoritesOnly(willActivate); if (willActivate) { setMarketTypeFilter('all'); } }, [showFavoritesOnly, setShowFavoritesOnly, setMarketTypeFilter, track]); + // Emit sort interaction, then apply the sort change to the list. + const handleSortChange = useCallback( + (optionId, field, sortDirection) => { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.SORT_APPLIED, + [PERPS_EVENT_PROPERTY.SORT_FIELD]: field, + [PERPS_EVENT_PROPERTY.SORT_DIRECTION]: sortDirection, + }); + handleOptionChange(optionId, field, sortDirection); + }, + [handleOptionChange, track], + ); + useEffect(() => { if (filteredMarkets.length > 0) { Animated.timing(fadeAnimation, { @@ -242,33 +381,219 @@ const PerpsMarketListView = ({ const handleBackPressed = perpsNavigation.navigateBack; - // Debounced search result_count tracking — fires ~600ms after the query/result - // count stabilises. Includes zero-result searches so analysts can measure failure. - const searchResultTimerRef = useRef | null>( - null, - ); + // emit the search query + results/no-results screen view. + // Stored in a ref (event-callback pattern) so both the debounce timer and the + // on-blur flush use the latest result count / filter context. + const emitSearchQueryRef = useRef< + (trimmedQuery: string, resultsSettled?: boolean) => void + >(() => { + // Assigned on every render below. + }); + // `resultsSettled` defaults to true (the debounced emit only runs once the + // markets list has settled). A blur/unmount flush while markets are still + // loading passes false: the result count is unknown, so the count-dependent + // props are omitted (never a stale/zero count) and no results screen view is + // recorded, but the query is still emitted so it is never silently dropped. + emitSearchQueryRef.current = ( + trimmedQuery: string, + resultsSettled = true, + ) => { + const normalizedQuery = trimmedQuery.toLowerCase(); + const resultCount = visibleSearchResults.length; + const hasResults = resultCount > 0; + const activeChips = [ + ...(showFavoritesOnly + ? [PERPS_EVENT_VALUE.BUTTON_CLICKED.WATCHLIST] + : []), + ...(marketTypeFilter !== 'all' ? [marketTypeFilter] : []), + ]; + // mode: chips/category narrow the set → discovery; a short ticker-like + // token → intent; free-text or empty context → browse. + const mode = activeChips.length + ? 'discovery' + : /^[a-z0-9]{1,6}$/.test(normalizedQuery) + ? 'intent' + : 'browse'; + + lastEmittedSearchQueryRef.current = normalizedQuery; + lastEmittedSearchResultsCountRef.current = resultsSettled + ? resultCount + : undefined; + if (resultsSettled) { + flushedUnsettledSearchQueryRef.current = null; + } + searchQueryCountRef.current += 1; + + track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: normalizedQuery, + query_text: normalizedQuery, + query_length: normalizedQuery.length, + ...(resultsSettled + ? { + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: resultCount, + [PERPS_EVENT_PROPERTY.RESULT_COUNT]: resultCount, + has_results: hasResults, + } + : {}), + [PERPS_EVENT_PROPERTY.MODE]: mode, + active_chips: activeChips, + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.PERP_MARKET_SEARCH, + }); + + // A results/no-results screen view is only meaningful once the counts are + // known; while loading no such screen has actually been shown yet. + if (resultsSettled) { + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { + [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: hasResults + ? PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_RESULTS_SHOWN + : PERPS_EVENT_VALUE.SCREEN_TYPE.SEARCH_NO_RESULTS, + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: normalizedQuery, + [PERPS_EVENT_PROPERTY.RESULT_COUNT]: resultCount, + }); + } + }; + + // Latest loading state readable from stable callbacks without dep churn. + const isLoadingMarketsRef = useRef(isLoadingMarkets); + isLoadingMarketsRef.current = isLoadingMarkets; + + // Reset the FULL search-session state so a new session never inherits a prior + // session's start time or query count. Called on every abandonment path + // (explicit clear, blur, unmount). + const resetSearchSession = useCallback(() => { + if (searchResultTimerRef.current) { + clearTimeout(searchResultTimerRef.current); + searchResultTimerRef.current = null; + } + pendingSearchQueryRef.current = null; + flushedUnsettledSearchQueryRef.current = null; + lastEmittedSearchQueryRef.current = ''; + lastEmittedSearchResultsCountRef.current = undefined; + searchStartTimeRef.current = null; + searchQueryCountRef.current = 0; + }, []); + + // Flush a query still awaiting the debounce so a blur/unmount records it + // before we decide on abandonment — it is never silently lost. When the + // markets list is still loading, the query is emitted with the count-dependent + // props omitted (unknown mid-load) rather than dropped or reported with a + // stale count. + const flushPendingSearchQuery = useCallback(() => { + if (searchResultTimerRef.current) { + clearTimeout(searchResultTimerRef.current); + searchResultTimerRef.current = null; + } + if (pendingSearchQueryRef.current) { + const resultsSettled = !isLoadingMarketsRef.current; + emitSearchQueryRef.current(pendingSearchQueryRef.current, resultsSettled); + flushedUnsettledSearchQueryRef.current = resultsSettled + ? null + : pendingSearchQueryRef.current.toLowerCase(); + pendingSearchQueryRef.current = null; + } + }, []); + flushPendingSearchQueryRef.current = flushPendingSearchQuery; + + const emitSearchAbandoned = useCallback(() => { + if (!lastEmittedSearchQueryRef.current) { + return; + } + track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: lastEmittedSearchQueryRef.current, + // Omitted (not 0) when the count never settled — e.g. abandoning while + // markets were still loading — matching PERPS_SEARCH_QUERY's handling. + ...(lastEmittedSearchResultsCountRef.current !== undefined + ? { + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: + lastEmittedSearchResultsCountRef.current, + } + : {}), + query_count: searchQueryCountRef.current, + ...(searchStartTimeRef.current !== null + ? { time_in_search_ms: Date.now() - searchStartTimeRef.current } + : {}), + }); + lastEmittedSearchQueryRef.current = ''; + lastEmittedSearchResultsCountRef.current = undefined; + }, [track]); + + // Debounced Search Query tracking — fires ~500ms after the query stabilises. + // Includes zero-result searches so analysts can measure failure. On an + // explicit clear, emits SEARCH_ABANDONED for any emitted query and resets the + // full search session. Emits the controller contract event PERPS_SEARCH_QUERY. useEffect(() => { const trimmedQuery = searchQuery.trim(); - if (!trimmedQuery) return; + if (!trimmedQuery) { + emitSearchAbandoned(); + resetSearchSession(); + return; + } + + if (searchStartTimeRef.current === null) { + searchStartTimeRef.current = Date.now(); + } if (searchResultTimerRef.current) { clearTimeout(searchResultTimerRef.current); + searchResultTimerRef.current = null; + } + const normalizedQuery = trimmedQuery.toLowerCase(); + if (flushedUnsettledSearchQueryRef.current !== normalizedQuery) { + flushedUnsettledSearchQueryRef.current = null; + } + pendingSearchQueryRef.current = trimmedQuery; + + // Wait for results to settle before scheduling the debounced emit so the + // reported result_count / has_results are never captured from the loading + // state. This effect re-runs (and reschedules) when loading completes or the + // result count changes, so the emitted count reflects the settled results. + if (isLoadingMarkets) { + return; + } + + if (flushedUnsettledSearchQueryRef.current === normalizedQuery) { + pendingSearchQueryRef.current = null; + return; } searchResultTimerRef.current = setTimeout(() => { - track(MetaMetricsEvents.PERPS_UI_INTERACTION, { - [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: - PERPS_EVENT_VALUE.INTERACTION_TYPE.SEARCH_CLICKED, - [PERPS_EVENT_PROPERTY.RESULT_COUNT]: filteredMarkets.length, - }); - }, 600); + emitSearchQueryRef.current(trimmedQuery); + pendingSearchQueryRef.current = null; + searchResultTimerRef.current = null; + }, 500); return () => { if (searchResultTimerRef.current) { clearTimeout(searchResultTimerRef.current); } }; - }, [searchQuery, filteredMarkets.length, track]); + }, [ + searchQuery, + isLoadingMarkets, + visibleSearchResults.length, + emitSearchAbandoned, + resetSearchSession, + ]); + + // Leaving the list with an emitted (or mid-debounce) un-tapped search query is + // an abandonment (blur / unmount / tab switch), not only an explicit clear. + // Flush any pending query first so it is counted, then abandon unless a result + // was tapped, then reset the full session so the next search starts clean. The + // suppressor is re-armed on focus so returning to an active search and leaving + // again still counts. + useFocusEffect( + useCallback(() => { + searchResultTappedRef.current = false; + return () => { + flushPendingSearchQuery(); + if (!searchResultTappedRef.current) { + emitSearchAbandoned(); + } + resetSearchSession(); + }; + }, [flushPendingSearchQuery, emitSearchAbandoned, resetSearchSession]), + ); // Performance tracking: Measure screen load time until market data is displayed usePerpsMeasurement({ @@ -353,29 +678,15 @@ const PerpsMarketListView = ({ // Mirrors PerpsHome behavior, without the collapsible "Show more" toggle. // Only reachable when the watchlist flag is enabled (pill is hidden otherwise). // When a search query is active both watchlist rows and suggested markets are - // filtered inline so the user can find any relevant market by name or symbol. + // filtered (via the memoized visibleWatchlistMarkets / visibleSuggestedMarkets + // above) so the user can find any relevant market by name or symbol, and so + // search analytics (visibleSearchResults) match what's rendered here. // "No tokens found" is only shown when nothing matches in either section. if (isWatchlistEnabled && showFavoritesOnly) { - const trimmedQuery = searchQuery.trim().toLowerCase(); - const visibleWatchlistMarkets = trimmedQuery - ? watchlistMarketObjects.filter( - (m) => - m.symbol.toLowerCase().includes(trimmedQuery) || - m.name.toLowerCase().includes(trimmedQuery), - ) - : watchlistMarketObjects; - const visibleSuggestedMarkets = trimmedQuery - ? suggestedMarkets?.filter( - (m) => - m.symbol.toLowerCase().includes(trimmedQuery) || - m.name.toLowerCase().includes(trimmedQuery), - ) - : suggestedMarkets; - if ( - trimmedQuery && + trimmedSearchQuery && visibleWatchlistMarkets.length === 0 && - !visibleSuggestedMarkets?.length + !visibleSuggestedMarkets.length ) { return ( setIsSortFieldSheetVisible(false)} selectedOptionId={selectedOptionId} sortDirection={direction} - onOptionSelect={handleOptionChange} + onOptionSelect={handleSortChange} testID={`${PerpsMarketListViewSelectorsIDs.SORT_FILTERS}-field-sheet`} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx index dafb377f53a..28f5681fd1b 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -920,6 +920,10 @@ describe('PerpsOrderBookView', () => { expect(mockNavigateToClosePosition).toHaveBeenCalledWith( mockLongPosition, 'order_book', + { + buttonClicked: 'close', + buttonLocation: 'order_book', + }, ); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index f6c68062851..cf574845cd4 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -576,6 +576,10 @@ const PerpsOrderBookView: React.FC = ({ navigateToClosePosition( existingPosition, PERPS_EVENT_VALUE.SOURCE.ORDER_BOOK, + { + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK, + }, ); }); }, [existingPosition, gate, navigateToClosePosition, isEligible, track]); diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx index d5b7cb3562c..5b8ff927497 100644 --- a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx @@ -212,10 +212,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: 'order-123', - symbol: 'xyz:MSTR', - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'order-123', + symbol: 'xyz:MSTR', + }), + ); }); }); @@ -290,10 +292,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: 'child-tp-order-123', - symbol: 'BTC', - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'child-tp-order-123', + symbol: 'BTC', + }), + ); }); }); @@ -503,10 +507,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: 'order-123', - symbol: 'BTC', - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'order-123', + symbol: 'BTC', + }), + ); }); await waitFor(() => { diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx index f0709bb667b..3c1be60b5cb 100644 --- a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx @@ -28,6 +28,7 @@ import { TraceName } from '../../../../../util/trace'; import { getPerpsDisplaySymbol, PERPS_CONSTANTS, + PERPS_EVENT_VALUE, type Order, } from '@metamask/perps-controller'; import styleSheet from './PerpsOrderDetailsView.styles'; @@ -39,6 +40,7 @@ import { formatOrderCardDate, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { formatOrderLabel, getOrderPositionDirection, @@ -214,6 +216,14 @@ const PerpsOrderDetailsView: React.FC = () => { const result = await cancelOrder({ orderId: order.orderId, symbol: order.symbol, + trackingData: { + totalFee, + marketPrice: priceMetrics.effectivePrice ?? 0, + source: PERPS_EVENT_VALUE.SOURCE.TRADE_SCREEN, + ...toPerpsEntryAttribution({ + source: PERPS_EVENT_VALUE.SOURCE.TRADE_SCREEN, + }), + }, }); // Show success/failure toast @@ -236,7 +246,16 @@ const PerpsOrderDetailsView: React.FC = () => { } finally { setIsCanceling(false); } - }, [order, canCancel, cancelOrder, navigation, showToast, PerpsToastOptions]); + }, [ + order, + canCancel, + cancelOrder, + navigation, + showToast, + PerpsToastOptions, + totalFee, + priceMetrics.effectivePrice, + ]); if (!order) { return ( diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index af96f31b38b..2c5d445f9eb 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -65,6 +65,11 @@ import { } from '../../providers/PerpsStreamManager'; import { usePerpsOrderContext } from '../../contexts/PerpsOrderContext'; import { useAnalytics } from '../../../../../components/hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; import PerpsOrderView from './PerpsOrderView'; jest.mock('@react-navigation/native', () => { @@ -162,6 +167,11 @@ jest.mock('../../hooks/stream', () => ({ usePerpsLiveFocusedPrice: jest.fn(() => undefined), })); +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + jest.mock('../../hooks/usePerpsNetworkManagement', () => ({ usePerpsNetworkManagement: () => ({ ensureArbitrumNetworkExists: jest.fn().mockResolvedValue(undefined), @@ -471,6 +481,20 @@ jest.mock( }), ); +// Controllable pay-quote state for the trade-quote-received coverage tests. +let mockIsPayQuoteLoading = false; +let mockPayTotals: unknown; +jest.mock( + '../../../../Views/confirmations/hooks/pay/useTransactionPayData', + () => ({ + ...jest.requireActual( + '../../../../Views/confirmations/hooks/pay/useTransactionPayData', + ), + useIsTransactionPayQuoteLoading: () => mockIsPayQuoteLoading, + useTransactionPayTotals: () => mockPayTotals, + }), +); + jest.mock( '../../../../Views/confirmations/hooks/transactions/useTransactionCustomAmount', () => ({ @@ -939,6 +963,7 @@ describe('PerpsOrderView', () => { (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate, goBack: mockGoBack, + addListener: jest.fn(() => jest.fn()), }); (useRoute as jest.Mock).mockReturnValue(defaultMockRoute); @@ -1228,6 +1253,44 @@ describe('PerpsOrderView', () => { expect(mockSubmitted).toHaveBeenCalled(); }); + it('includes discovery attribution from route source_section in order trackingData', async () => { + const mockExecuteOrder = jest.fn().mockResolvedValue({ success: true }); + (useRoute as jest.Mock).mockReturnValue({ + params: { + asset: 'ETH', + direction: 'long', + source: 'perp_asset_screen', + source_section: 'watchlist', + }, + }); + (usePerpsOrderExecution as jest.Mock).mockImplementation(() => ({ + placeOrder: mockExecuteOrder, + isPlacing: false, + })); + + render(, { wrapper: TestWrapper }); + + const placeOrderButton = await screen.findByTestId( + PerpsOrderViewSelectorsIDs.PLACE_ORDER_BUTTON, + ); + await act(async () => { + fireEvent.press(placeOrderButton); + }); + + await waitFor(() => { + expect(mockExecuteOrder).toHaveBeenCalled(); + }); + expect(mockExecuteOrder).toHaveBeenCalledWith( + expect.objectContaining({ + trackingData: expect.objectContaining({ + entryPoint: 'perp_asset_screen', + discoverySource: 'watchlist', + perpDiscoverySource: 'watchlist', + }), + }), + ); + }); + it('shows standard submitted toast when using perps balance', async () => { mockUseIsPerpsBalanceSelected.mockReturnValue(true); @@ -4452,4 +4515,208 @@ describe('PerpsOrderView', () => { expect(mockPlaceOrder).not.toHaveBeenCalled(); }); }); + + describe('transaction considered + trade quote received', () => { + let captured: { eventName: unknown; props: Record }[]; + + beforeEach(() => { + captured = []; + mockIsPayQuoteLoading = false; + mockPayTotals = undefined; + mockCreateEventBuilder.mockImplementation((eventName?: unknown) => { + const builder: { addProperties: jest.Mock; build: jest.Mock } = { + addProperties: jest.fn((props: Record) => { + captured.push({ eventName, props }); + return builder; + }), + build: jest.fn(() => ({})), + }; + return builder; + }); + }); + + const eventsOf = (name: unknown) => + captured.filter((e) => e.eventName === name); + + it('emits PERPS_TRANSACTION_CONSIDERED once the filled order form settles (1s debounce)', () => { + jest.useFakeTimers(); + render(, { wrapper: TestWrapper }); + + // Nothing before the 1s debounce window elapses. + act(() => { + jest.advanceTimersByTime(999); + }); + expect( + eventsOf(MetaMetricsEvents.PERPS_TRANSACTION_CONSIDERED), + ).toHaveLength(0); + + act(() => { + jest.advanceTimersByTime(1); + }); + const considered = eventsOf( + MetaMetricsEvents.PERPS_TRANSACTION_CONSIDERED, + ); + expect(considered).toHaveLength(1); + expect(considered[0].props).toEqual( + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.ORDER_CONTEXT]: 'trade', + [PERPS_EVENT_PROPERTY.ACTION]: + PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: 11, + [PERPS_EVENT_PROPERTY.INPUT_METHOD]: 'default', + [PERPS_EVENT_PROPERTY.ASSET]: 'ETH', + [PERPS_EVENT_PROPERTY.DIRECTION]: PERPS_EVENT_VALUE.DIRECTION.LONG, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: 'market', + [PERPS_EVENT_PROPERTY.ORDER_HAS_TP]: false, + [PERPS_EVENT_PROPERTY.ORDER_HAS_SL]: false, + [PERPS_EVENT_PROPERTY.LEVERAGE]: 3, + [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: true, + }), + ); + + jest.useRealTimers(); + }); + + it('emits PERPS_TRADE_QUOTE_RECEIVED with quote_latency_ms when a pay-token quote completes', () => { + // Quote request in flight (custom pay token selected by default). + mockIsPayQuoteLoading = true; + const { rerender } = render(, { wrapper: TestWrapper }); + + // Quote resolves: loading transitions true -> false with totals present. + mockIsPayQuoteLoading = false; + mockPayTotals = { fees: {} }; + act(() => { + rerender(); + }); + + const quotes = eventsOf(MetaMetricsEvents.PERPS_TRADE_QUOTE_RECEIVED); + expect(quotes).toHaveLength(1); + expect(quotes[0].props).toEqual( + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.ASSET]: 'ETH', + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.SUCCESS, + [PERPS_EVENT_PROPERTY.QUOTE_LATENCY_MS]: expect.any(Number), + }), + ); + }); + + it('emits a distinct PERPS_TRADE_QUOTE_RECEIVED per failed attempt when the amount changes (same blocking alert)', () => { + const { useNoPayTokenQuotesAlert: mockNoQuotes } = jest.requireMock( + '../../../../Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert', + ) as { useNoPayTokenQuotesAlert: jest.Mock }; + // Same blocking no-quotes alert for both attempts; a failed quote carries + // no payTotals, so only the amount distinguishes the two attempts. + mockNoQuotes.mockReturnValue([ + { key: 'no_quotes', message: 'No quotes available', isBlocking: true }, + ]); + + try { + const { rerender } = render(, { + wrapper: TestWrapper, + }); + + // Second attempt: user edits the amount, same failing alert. + (usePerpsOrderContext as jest.Mock).mockReturnValue({ + ...defaultMockHooks.usePerpsOrderContext, + orderForm: { + ...defaultMockHooks.usePerpsOrderContext.orderForm, + amount: '22', + }, + }); + act(() => { + rerender(); + }); + + const quotes = eventsOf(MetaMetricsEvents.PERPS_TRADE_QUOTE_RECEIVED); + expect(quotes).toHaveLength(2); + quotes.forEach((q) => + expect(q.props).toEqual( + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, + }), + ), + ); + } finally { + mockNoQuotes.mockReturnValue([]); + } + }); + }); + + describe('abandon order tracking', () => { + let captured: { eventName: unknown; props: Record }[]; + + const isAbandonEvent = (event: { + eventName: unknown; + props: Record; + }) => + event.eventName === MetaMetricsEvents.PERPS_UI_INTERACTION && + event.props?.[PERPS_EVENT_PROPERTY.ACTION] === + PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER; + + const setupAbandonNav = (routes: { key: string }[]) => { + const listeners: Record void)[]> = {}; + (useNavigation as jest.Mock).mockReturnValue({ + navigate: mockNavigate, + goBack: mockGoBack, + dispatch: jest.fn(), + addListener: jest.fn((event: string, cb: () => void) => { + (listeners[event] = listeners[event] || []).push(cb); + return jest.fn(); + }), + getState: jest.fn(() => ({ routes })), + getParent: jest.fn(() => undefined), + }); + return (event: string) => (listeners[event] || []).forEach((cb) => cb()); + }; + + beforeEach(() => { + captured = []; + mockCreateEventBuilder.mockImplementation((eventName?: unknown) => { + const builder: { addProperties: jest.Mock; build: jest.Mock } = { + addProperties: jest.fn((props: Record) => { + captured.push({ eventName, props }); + return builder; + }), + build: jest.fn(() => ({})), + }; + return builder; + }); + }); + + it('emits abandon_order on beforeRemove (back / hardware back)', () => { + const fire = setupAbandonNav([{ key: 'trade' }]); + render(, { wrapper: TestWrapper }); + + act(() => fire('beforeRemove')); + + expect(captured.some(isAbandonEvent)).toBe(true); + }); + + it('emits abandon_order on tab-away (blur with unchanged depth)', () => { + const fire = setupAbandonNav([{ key: 'trade' }]); + render(, { wrapper: TestWrapper }); + + act(() => fire('blur')); + + expect(captured.some(isAbandonEvent)).toBe(true); + }); + + it('does NOT emit on blur when a child route was pushed (depth increased)', () => { + const routes = [{ key: 'trade' }]; + const fire = setupAbandonNav(routes); + render(, { wrapper: TestWrapper }); + + routes.push({ key: 'child' }); + act(() => fire('blur')); + + expect(captured.some(isAbandonEvent)).toBe(false); + }); + + // Note: committed-order suppression (hasPlacedOrderRef) is covered + // deterministically by the usePerpsAbandonOrderTracking hook unit test and by + // the PerpsClosePositionView "does NOT emit after a confirmed close" + // integration test (which drives the real confirm button). Driving the full + // place-order flow here is too dependent on cross-test mock state to assert + // reliably. + }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6d9b1688d65..a9484def4a5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -126,6 +126,7 @@ import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { useABTest } from '../../../../../hooks/useABTest'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { usePerpsAbandonOrderTracking } from '../../hooks/usePerpsAbandonOrderTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { buildPerpsCufStartTags } from '../../utils/perpsCufTrace'; import { PERPS_CUF_TAG, PERPS_CUF_VARIANT } from '../../constants/perpsCufTags'; @@ -146,6 +147,8 @@ import { PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { willFlipPosition } from '../../utils/orderUtils'; +import { derivePerpsTradeAction } from '../../utils/deriveTradeAction'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, @@ -183,6 +186,8 @@ interface OrderRouteParams { fromTokenDetails?: boolean; /** Analytics: how the user got to the order screen (e.g. trade_action, order_book_long_button, asset_detail_screen) */ source?: string; + /** Analytics: market-list discovery section forwarded from market details */ + source_section?: string; /** Analytics: chart library active when the order flow started */ chartLibrary?: string; defaultSzDecimals?: number; @@ -227,6 +232,7 @@ const PerpsOrderViewContentBase: React.FC = ({ (TrendingFeedSessionManager.getInstance().isFromTrending ? 'trending' : undefined); + const sourceSection = route.params?.source_section; const isAdvancedChartEnabled = useSelector( selectPerpsAdvancedChartEnabledFlag, ); @@ -301,9 +307,14 @@ const PerpsOrderViewContentBase: React.FC = ({ const orderTypeRef = useRef('market'); const isSubmittingRef = useRef(false); - const orderStartTimeRef = useRef(0); const inputMethodRef = useRef('default'); + // Track whether the user actually placed a trade so leaving the + // screen without one (back swipe, hardware back, tab switch) is emitted as an + // abandon interaction via the focus-effect cleanup below. + const hasPlacedOrderRef = useRef(false); + const latestAbandonPropsRef = useRef>({}); + const { isInitialized } = usePerpsConnection(); const { subscribeToPrices, updatePositionTPSL } = usePerpsTrading(); const { account, isInitialLoading: isLoadingAccount } = usePerpsLiveAccount(); @@ -441,6 +452,9 @@ const PerpsOrderViewContentBase: React.FC = ({ [PERPS_EVENT_PROPERTY.CHART_LIBRARY]: chartLibrary, [PERPS_EVENT_PROPERTY.ASSET_TYPE]: PERPS_EVENT_VALUE.ASSET_TYPE.PERP, [PERPS_EVENT_PROPERTY.OPEN_POSITION]: currentMarketPosition ? 1 : 0, + [PERPS_EVENT_PROPERTY.HAS_PERP_BALANCE]: Boolean( + account?.totalBalance && Number.parseFloat(account.totalBalance) > 0, + ), [PERPS_EVENT_PROPERTY.OUTAGE_BANNER_SHOWN]: isServiceInterruptionBannerEnabled, }; @@ -772,6 +786,182 @@ const PerpsOrderViewContentBase: React.FC = ({ }, }); + const currentMarketPositionSize = currentMarketPosition?.size; + const consideredTradeAction = useMemo( + () => + derivePerpsTradeAction( + currentMarketPositionSize ? { size: currentMarketPositionSize } : null, + orderForm.direction, + ), + [currentMarketPositionSize, orderForm.direction], + ); + + // Emit "transaction considered" once the user has a stable, + // meaningful fill. Debounced 1s and reset on each change so it fires once per + // settled fill instead of on every keystroke. + useEffect(() => { + const orderSize = parseFloat(orderForm.amount); + if (!(orderSize > 0)) { + return; + } + + const timeoutId = setTimeout(() => { + const consideredProps: Record = { + // No ORDER_CONTEXT value enum exists; 'trade' denotes the open-order + // screen (the close screen would be 'close'). + [PERPS_EVENT_PROPERTY.ORDER_CONTEXT]: 'trade', + [PERPS_EVENT_PROPERTY.ACTION]: consideredTradeAction, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderSize, + [PERPS_EVENT_PROPERTY.INPUT_METHOD]: inputMethodRef.current, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.DIRECTION]: + orderForm.direction === 'long' + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_TYPE]: orderForm.type, + [PERPS_EVENT_PROPERTY.ORDER_HAS_TP]: Boolean(orderForm.takeProfitPrice), + [PERPS_EVENT_PROPERTY.ORDER_HAS_SL]: Boolean(orderForm.stopLossPrice), + [PERPS_EVENT_PROPERTY.LEVERAGE]: orderForm.leverage, + [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: hasCustomTokenSelected, + }; + if (hasCustomTokenSelected && payToken) { + consideredProps[PERPS_EVENT_PROPERTY.FROM_TOKEN] = payToken.symbol; + consideredProps[PERPS_EVENT_PROPERTY.FROM_CHAIN] = payToken.chainId; + } + track(MetaMetricsEvents.PERPS_TRANSACTION_CONSIDERED, consideredProps); + }, 1000); + + return () => clearTimeout(timeoutId); + }, [ + orderForm.amount, + orderForm.asset, + orderForm.direction, + orderForm.type, + orderForm.leverage, + orderForm.takeProfitPrice, + orderForm.stopLossPrice, + consideredTradeAction, + hasCustomTokenSelected, + payToken, + track, + ]); + + // Emit "trade quote received" when a pay-with-token relay quote + // request completes. Loading transitions provide real latency; cached quotes + // that are already settled emit once with zero latency instead of being + // skipped because no loading start was observed. + const payQuoteStartRef = useRef(null); + const lastEmittedPayQuoteKeyRef = useRef(null); + useEffect(() => { + if (!hasCustomTokenSelected) { + payQuoteStartRef.current = null; + lastEmittedPayQuoteKeyRef.current = null; + return; + } + + if (isPayTotalsLoading) { + // Start timing on the first observation of an in-flight quote — this + // covers both a fresh false→true transition and a quote already loading + // when this effect first runs (custom token pre-selected), so a + // completing quote is never missed for lack of a recorded start. + if (payQuoteStartRef.current === null) { + payQuoteStartRef.current = Date.now(); + } + return; + } + + const blockingNoQuoteAlert = noQuotesAlerts.find((a) => a.isBlocking); + const succeeded = Boolean(payTotals) && !blockingNoQuoteAlert; + + if (!payTotals && !blockingNoQuoteAlert) { + payQuoteStartRef.current = null; + return; + } + + // Include the amount and pay-token identity so a retry after changing them + // is a distinct attempt, not deduped against the previous one — a failed + // quote carries no payTotals, so without these a repeated failure with the + // same blocking alert would be silently undercounted. + const quoteKey = JSON.stringify({ + asset: orderForm.asset, + amount: orderForm.amount, + payToken: payToken + ? `${payToken.symbol ?? ''}:${payToken.chainId ?? ''}` + : null, + payTotals, + errorKey: blockingNoQuoteAlert?.key, + errorMessage: blockingNoQuoteAlert?.message, + }); + + if (lastEmittedPayQuoteKeyRef.current === quoteKey) { + payQuoteStartRef.current = null; + return; + } + + const latencyMs = + payQuoteStartRef.current === null + ? 0 + : Date.now() - payQuoteStartRef.current; + payQuoteStartRef.current = null; + lastEmittedPayQuoteKeyRef.current = quoteKey; + + const quoteProps: Record = { + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.STATUS]: succeeded + ? PERPS_EVENT_VALUE.STATUS.SUCCESS + : PERPS_EVENT_VALUE.STATUS.FAILED, + [PERPS_EVENT_PROPERTY.QUOTE_LATENCY_MS]: latencyMs, + }; + if (!succeeded && blockingNoQuoteAlert) { + if (typeof blockingNoQuoteAlert.message === 'string') { + quoteProps[PERPS_EVENT_PROPERTY.ERROR_MESSAGE] = + blockingNoQuoteAlert.message; + } + if (blockingNoQuoteAlert.key) { + quoteProps[PERPS_EVENT_PROPERTY.ERROR_REASON] = + blockingNoQuoteAlert.key; + } + } + track(MetaMetricsEvents.PERPS_TRADE_QUOTE_RECEIVED, quoteProps); + }, [ + isPayTotalsLoading, + hasCustomTokenSelected, + payTotals, + noQuotesAlerts, + orderForm.asset, + orderForm.amount, + payToken, + track, + ]); + + // Snapshot of the abandon-order props, refreshed each render so the + // focus-effect cleanup emits the latest form state when the user leaves. + latestAbandonPropsRef.current = { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + [PERPS_EVENT_PROPERTY.ACTION]: PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.DIRECTION]: + orderForm.direction === 'long' + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + [PERPS_EVENT_PROPERTY.ORDER_SIZE]: parseFloat(orderForm.amount || '0'), + [PERPS_EVENT_PROPERTY.LEVERAGE_USED]: orderForm.leverage, + }; + + // emit abandon_order on a real exit (back swipe, hardware back, + // programmatic dismissal) AND on a genuine tab switch away, but never when a + // child route is pushed (TP/SL screen, cross-margin modal, payment-token + // selector) or after placement (hasPlacedOrderRef). + const getAbandonProperties = useCallback( + () => latestAbandonPropsRef.current, + [], + ); + usePerpsAbandonOrderTracking({ + getAbandonProperties, + hasCommittedRef: hasPlacedOrderRef, + }); + // Order execution hook. Shows standard "Order submitted" toast for all order flows. const { placeOrder: executeOrder, isPlacing: isPlacingOrder } = usePerpsOrderExecution({ @@ -1048,6 +1238,26 @@ const PerpsOrderViewContentBase: React.FC = ({ return; } + // Track the Place Order button press for ALL users on the real + // tap — emitted BEFORE the deposit/direct branching so deposit-path taps + // are captured too (the deposit branch returns early). `forceTrade` marks + // the post-deposit re-invocation, not a user tap, so it is excluded. The + // active A/B assignment (e.g. button color) is auto-injected onto this + // PERPS_UI_INTERACTION event via enrichWithABTests(). + if (!forceTrade) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.PLACE_ORDER, + [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, + [PERPS_EVENT_PROPERTY.DIRECTION]: + orderForm.direction === 'long' + ? PERPS_EVENT_VALUE.DIRECTION.LONG + : PERPS_EVENT_VALUE.DIRECTION.SHORT, + }); + } + // Bail out before the pay-with-any-token deposit branch so an // excessive-slippage order never starts a deposit/signature flow. if (exceedsMaxSlippage && typeof estimatedSlippageBps === 'number') { @@ -1095,7 +1305,23 @@ const PerpsOrderViewContentBase: React.FC = ({ handleDepositConfirm(activeTransactionMeta, () => { handlePlaceOrder(true); }); - await onDepositConfirm(); + // useTransactionConfirm swallows confirm errors and reports them via + // onError, so capture failure explicitly instead of assuming success. + let depositConfirmError: unknown; + await onDepositConfirm({ + onError: (error) => { + depositConfirmError = error; + }, + }); + if (depositConfirmError) { + // A cancelled/failed deposit confirmation is not a commitment: keep + // abandon tracking armed and stay on the screen so the user can retry + // (leaving later then correctly counts as an abandon). + return; + } + // Deposit confirmed: the order is placed once funds arrive, so leaving + // now is a real commitment, not an abandoned order. + hasPlacedOrderRef.current = true; if (fromTokenDetails) { navigation.dispatch( CommonActions.reset({ @@ -1124,20 +1350,13 @@ const PerpsOrderViewContentBase: React.FC = ({ // No deposit needed, place order directly isSubmittingRef.current = true; - orderStartTimeRef.current = Date.now(); - - // Track Place Order button press with A/B test context - if (isButtonColorTestEnabled) { - track(MetaMetricsEvents.PERPS_UI_INTERACTION, { - [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: - PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, - [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, - [PERPS_EVENT_PROPERTY.DIRECTION]: - orderForm.direction === 'long' - ? PERPS_EVENT_VALUE.DIRECTION.LONG - : PERPS_EVENT_VALUE.DIRECTION.SHORT, - }); - } + // Note: order_execution_latency_ms is intentionally not emitted + // here — the terminal Perp transaction event is owned by the perps + // controller, and the latency field lands with the @metamask/perps-controller + // 9.2.2 bump (TrackingData.orderExecutionLatencyMs) in a follow-up PR. + // The Place Order button press (incl. A/B context) is already tracked at + // the top of this callback for all paths; the button-color assignment is + // auto-injected onto PERPS_UI_INTERACTION via enrichWithABTests(). try { // Validation errors are shown in the UI @@ -1193,6 +1412,9 @@ const PerpsOrderViewContentBase: React.FC = ({ const monitorOrders = true; const monitorPositions = true; + // Real placement is underway; leaving now is not an abandon. + hasPlacedOrderRef.current = true; + navigation.navigate(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, params: { @@ -1242,7 +1464,7 @@ const PerpsOrderViewContentBase: React.FC = ({ : {}), ...tpParams, ...slParams, - // Add tracking data for MetaMetrics events + // Add tracking data for MetaMetrics events (controller owns emission) trackingData: { marginUsed: Number(marginRequired), totalFee: feeResults.totalFee, @@ -1253,10 +1475,15 @@ const PerpsOrderViewContentBase: React.FC = ({ estimatedPoints: feeResults.estimatedPoints, inputMethod: inputMethodRef.current, source, + ...toPerpsEntryAttribution({ source, sourceSection }), + ...(feeResults.protocolFeeRate !== undefined + ? { hlFeeRate: feeResults.protocolFeeRate } + : {}), + tradeAction: derivePerpsTradeAction( + currentMarketPosition, + orderForm.direction, + ), chartLibrary, - tradeAction: currentMarketPosition - ? 'increase_exposure' - : 'create_position', tradeWithToken: hasCustomTokenSelected, ...(hasCustomTokenSelected && payToken && { @@ -1265,6 +1492,10 @@ const PerpsOrderViewContentBase: React.FC = ({ }), vipTier: vipTier ?? undefined, vipDiscount: feeResults.feeDiscountPercentage, + // Cast needed only because the installed perps-controller 9.2.1 + // TradeAction type is narrow (create_position | increase_exposure) + // and does not yet include the flip values. The next release widens + // it, after which this cast can be dropped. } as OrderParams['trackingData'], }; @@ -1348,11 +1579,12 @@ const PerpsOrderViewContentBase: React.FC = ({ feeResults.totalFee, feeResults.metamaskFee, feeResults.metamaskFeeRate, + feeResults.protocolFeeRate, feeResults.feeDiscountPercentage, feeResults.estimatedPoints, source, + sourceSection, chartLibrary, - isButtonColorTestEnabled, isTradeWithAnyTokenEnabled, depositAmount, activeTransactionMeta, diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx index eb26273896d..897686725d4 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.test.tsx @@ -8,6 +8,10 @@ import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected import { useTokenWithBalance } from '../../../../Views/confirmations/hooks/tokens/useTokenWithBalance'; import { useConfirmationMetricEvents } from '../../../../Views/confirmations/hooks/metrics/useConfirmationMetricEvents'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { + markPerpsPaymentTokenSelection, + resetPerpsPaymentTokenSelection, +} from '../../utils/perpsPaymentTokenSelection'; import { isHardwareAccount } from '../../../../../util/address'; import Routes from '../../../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; @@ -28,6 +32,8 @@ jest.mock('../../../../../../locales/i18n', () => ({ jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: jest.fn(), + // Run the focus callback on every render so dismiss detection is testable. + useFocusEffect: jest.fn((callback) => callback()), })); jest.mock('../../../../Views/confirmations/hooks/pay/useTransactionPayToken'); @@ -83,6 +89,7 @@ describe('PerpsPayRow', () => { beforeEach(() => { jest.clearAllMocks(); + resetPerpsPaymentTokenSelection(); mockUseNavigation.mockReturnValue({ navigate: navigateMock, } as unknown as ReturnType); @@ -183,4 +190,105 @@ describe('PerpsPayRow', () => { expect(onPayWithInfoPress).toHaveBeenCalledTimes(1); }); + + describe('payment token selector dismissal', () => { + const dismissedCall = [ + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + }), + ]; + + const setPayToken = (token: { + address: string; + chainId: string; + symbol: string; + }) => + mockUseTransactionPayToken.mockReturnValue({ + payToken: token, + setPayToken: jest.fn(), + } as unknown as ReturnType); + + it('emits payment_token_selector_dismissed when the selector closes with the token unchanged', () => { + const usdc = { address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }; + setPayToken(usdc); + const { getByTestId, rerender } = renderWithProvider(); + + // Open the selector, then return to the screen with the same token. + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + rerender(); + + expect(trackMock).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_UI_INTERACTION, + { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + [PERPS_EVENT_PROPERTY.CURRENT_TOKEN]: 'USDC', + }, + ); + }); + + it('does NOT emit dismissed when a different token is selected', () => { + setPayToken({ address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }); + const { getByTestId, rerender } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + // A different token (different address) was chosen. + setPayToken({ address: '0xweth', chainId: '0xa4b1', symbol: 'WETH' }); + rerender(); + + expect(trackMock).not.toHaveBeenCalledWith(...dismissedCall); + }); + + it('does NOT emit dismissed when the current token row is re-pressed (explicit selection, unchanged identity)', () => { + setPayToken({ address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }); + const { getByTestId, rerender } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + // The user pressed the already-selected row (a no-op for token identity) + // — the selector rows record this as an explicit selection. + markPerpsPaymentTokenSelection(); + rerender(); + + expect(trackMock).not.toHaveBeenCalledWith(...dismissedCall); + }); + + it('does NOT emit dismissed when a different token that shares a symbol is selected', () => { + setPayToken({ address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }); + const { getByTestId, rerender } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + // Same symbol, different address/chain → identity changed, real selection. + setPayToken({ address: '0xusdc2', chainId: '0x1', symbol: 'USDC' }); + rerender(); + + expect(trackMock).not.toHaveBeenCalledWith(...dismissedCall); + }); + + it('emits dismissed at most once per selector open', () => { + setPayToken({ address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }); + const { getByTestId, rerender } = renderWithProvider(); + + fireEvent.press(getByTestId(ConfirmationRowComponentIDs.PAY_WITH)); + rerender(); + rerender(); + + const dismissedCalls = trackMock.mock.calls.filter( + ([, props]) => + props?.[PERPS_EVENT_PROPERTY.INTERACTION_TYPE] === + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + ); + expect(dismissedCalls).toHaveLength(1); + }); + + it('does NOT emit dismissed on a plain focus with no selector open', () => { + setPayToken({ address: '0xusdc', chainId: '0xa4b1', symbol: 'USDC' }); + const { rerender } = renderWithProvider(); + + rerender(); + + expect(trackMock).not.toHaveBeenCalledWith(...dismissedCall); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx index 0af4ba64fa3..8245e9b846a 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsPayRow.tsx @@ -7,8 +7,8 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { useNavigation } from '@react-navigation/native'; -import React, { useCallback, useMemo } from 'react'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo, useRef } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; import Badge, { @@ -40,6 +40,10 @@ import { import { PERPS_BALANCE_ICON_URI } from '../../hooks/usePerpsBalanceTokenFilter'; import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { + consumePerpsPaymentTokenSelection, + resetPerpsPaymentTokenSelection, +} from '../../utils/perpsPaymentTokenSelection'; import { Hex } from '@metamask/utils'; import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; @@ -70,6 +74,15 @@ export const PerpsPayRow = ({ onPayWithInfoPress }: PerpsPayRowProps) => { const canEdit = !isHardwareAccount(from ?? ''); + // stable token identity (address + chainId, not symbol) captured + // when the selector opens. Comparing identity avoids misclassifying a + // selection of a different token that shares a symbol as a dismissal. `null` + // means no selector open is pending. + const payTokenIdentity = payToken + ? `${payToken.address}:${payToken.chainId}` + : ''; + const selectorOpenIdentityRef = useRef(null); + const handleClick = useCallback(() => { if (!canEdit) return; setConfirmationMetric({ @@ -81,8 +94,34 @@ export const PerpsPayRow = ({ onPayWithInfoPress }: PerpsPayRowProps) => { [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR, }); + selectorOpenIdentityRef.current = payTokenIdentity; + // Clear any stale selection marker so only a press during THIS open counts. + resetPerpsPaymentTokenSelection(); navigation.navigate(Routes.CONFIRMATION_PAY_WITH_BOTTOM_SHEET); - }, [canEdit, navigation, setConfirmationMetric, track]); + }, [canEdit, navigation, payTokenIdentity, setConfirmationMetric, track]); + + // emit payment_token_selector_dismissed when this screen regains + // focus after the selector closes WITHOUT an explicit row selection. A row + // press (even re-selecting the current token, which leaves identity unchanged) + // sets the selection marker; only a true dismiss with no press and unchanged + // identity is reported. A pending ref set on open guards the initial mount. + useFocusEffect( + useCallback(() => { + const identityAtOpen = selectorOpenIdentityRef.current; + if (identityAtOpen === null) { + return; + } + selectorOpenIdentityRef.current = null; + const selectionMade = consumePerpsPaymentTokenSelection(); + if (!selectionMade && payTokenIdentity === identityAtOpen) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.PAYMENT_TOKEN_SELECTOR_DISMISSED, + [PERPS_EVENT_PROPERTY.CURRENT_TOKEN]: payToken?.symbol, + }); + } + }, [payTokenIdentity, payToken, track]), + ); const displayToken = matchesPerpsBalance ? { diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx index 8a1bb8a6091..e0028c2edbb 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.test.tsx @@ -210,6 +210,10 @@ describe('PerpsSelectModifyActionView', () => { expect(mockNavigateToClosePosition).toHaveBeenCalledWith( mockLongPosition, 'position_screen', + { + buttonClicked: 'reduce_exposure', + buttonLocation: 'screen', + }, ); }); diff --git a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx index 5b0ebb71825..73cfe496a4b 100644 --- a/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx +++ b/app/components/UI/Perps/Views/PerpsSelectModifyActionView/PerpsSelectModifyActionView.tsx @@ -107,10 +107,14 @@ const PerpsSelectModifyActionView: React.FC< break; case 'reduce_position': - // Open close position screen + // Open close position screen — this entry is the reduce-exposure CTA. navigateToClosePosition( position, PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + { + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, + }, ); break; diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx index 5f15d0644ea..455a257215c 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx @@ -5,6 +5,11 @@ import { PERPS_EVENT_VALUE, type Position } from '@metamask/perps-controller'; // react-native-reanimated is already mocked globally via setUpTests() in testSetup.js +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + jest.mock('react-native-gesture-handler', () => ({ GestureHandlerRootView: 'View', GestureDetector: 'View', @@ -579,7 +584,7 @@ describe('PerpsTPSLView', () => { undefined, '3150.00', '2850.00', - { + expect.objectContaining({ direction: 'long', source: PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN, positionSize: 0, @@ -587,7 +592,7 @@ describe('PerpsTPSLView', () => { stopLossPercentage: undefined, isEditingExistingPosition: false, entryPrice: 3000, - }, + }), ); }); @@ -648,7 +653,7 @@ describe('PerpsTPSLView', () => { undefined, undefined, undefined, - { + expect.objectContaining({ direction: 'long', source: PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN, positionSize: 0, @@ -656,7 +661,7 @@ describe('PerpsTPSLView', () => { stopLossPercentage: undefined, isEditingExistingPosition: false, entryPrice: 3000, - }, + }), ); }); diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index 148de99512a..8334e383286 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx @@ -47,6 +47,7 @@ import { PRICE_RANGES_UNIVERSAL, PRICE_RANGES_MINIMAL_VIEW, } from '../../utils/formatUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { TP_SL_VIEW_CONFIG } from '../../constants/perpsConfig'; import { TPSL_ROE_SIGN_TOGGLED_INTERACTION, @@ -397,11 +398,13 @@ const PerpsTPSLView: React.FC = () => { // Use appropriate source based on context: // - POSITION_SCREEN when editing TP/SL on an existing position // - TRADE_SCREEN when setting TP/SL for a new order + const riskSource = isEditingExistingPosition + ? PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.POSITION_SCREEN + : PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN; const trackingData = { direction: actualDirection, - source: isEditingExistingPosition - ? PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.POSITION_SCREEN - : PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN, + source: riskSource, + ...toPerpsEntryAttribution({ source: riskSource }), positionSize: position?.size ? Math.abs(parseFloat(position.size)) : 0, // Display strings are unsigned magnitudes (the badge renders the sign), // so recompose the sign for analytics — a negative TP / gain-side SL diff --git a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx index 0c4be82bcfc..f9d3822b192 100644 --- a/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsWithdrawView/PerpsWithdrawView.test.tsx @@ -16,6 +16,11 @@ import PerpsWithdrawView from './PerpsWithdrawView'; import { ToastContext } from '../../../../../component-library/components/Toast'; // Mock component-library Button to avoid IconSize import issues during tests +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + jest.mock('../../../../../component-library/components/Buttons/Button', () => ({ __esModule: true, default: ({ diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx index 0b416485fc3..c545b5fb701 100644 --- a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx +++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.test.tsx @@ -98,6 +98,11 @@ jest.mock('../../utils/formatUtils', () => ({ jest.mock('@metamask/perps-controller', () => ({ getPerpsDisplaySymbol: jest.fn((symbol) => symbol), + PERPS_EVENT_VALUE: { + SOURCE: { + POSITION_SCREEN: 'position_screen', + }, + }, })); jest.mock('../PerpsFeesDisplay', () => { diff --git a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx index 984bde86e0e..dd1a1601ebd 100644 --- a/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx +++ b/app/components/UI/Perps/components/PerpsFlipPositionConfirmSheet/PerpsFlipPositionConfirmSheet.tsx @@ -24,7 +24,11 @@ import { } from '../../hooks'; import { usePerpsFlipPosition } from '../../hooks/usePerpsFlipPosition'; import { usePerpsLivePrices, usePerpsTopOfBook } from '../../hooks/stream'; -import { getPerpsDisplaySymbol } from '@metamask/perps-controller'; +import { + getPerpsDisplaySymbol, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import PerpsFeesDisplay from '../PerpsFeesDisplay'; import RewardsAnimations, { RewardAnimationState, @@ -133,15 +137,27 @@ const PerpsFlipPositionConfirmSheet: React.FC< const handleReverse = useCallback(async () => { await handleFlipPosition(position, { totalFee: feeResults.totalFee, + metamaskFee: feeResults.metamaskFee, + metamaskFeeRate: feeResults.metamaskFeeRate, marketPrice: markPrice || price, vipTier: vipTier ?? undefined, vipDiscount: feeResults.feeDiscountPercentage, + ...toPerpsEntryAttribution({ + source: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + }), + source: PERPS_EVENT_VALUE.SOURCE.POSITION_SCREEN, + ...(feeResults.protocolFeeRate !== undefined + ? { hlFeeRate: feeResults.protocolFeeRate } + : {}), }); }, [ position, handleFlipPosition, feeResults.totalFee, + feeResults.metamaskFee, + feeResults.metamaskFeeRate, feeResults.feeDiscountPercentage, + feeResults.protocolFeeRate, markPrice, price, vipTier, diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index 0b17436e2c7..04ee892a71c 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -920,10 +920,12 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: btcOrder.orderId, - symbol: btcOrder.symbol, - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: btcOrder.orderId, + symbol: btcOrder.symbol, + }), + ); }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -973,10 +975,12 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: btcOrder.orderId, - symbol: btcOrder.symbol, - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: btcOrder.orderId, + symbol: btcOrder.symbol, + }), + ); }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -1020,10 +1024,12 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ - orderId: btcOrder.orderId, - symbol: btcOrder.symbol, - }); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: btcOrder.orderId, + symbol: btcOrder.symbol, + }), + ); }); expect(mockShowToast).toHaveBeenCalledTimes(2); diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index 7338a741916..538b4dbba7c 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -32,6 +32,7 @@ import styleSheet from './PerpsMarketTabs.styles'; import { OrderDirection, PERPS_CONSTANTS, + PERPS_EVENT_VALUE, type Order, type Position, type TPSLTrackingData, @@ -52,6 +53,7 @@ import PerpsOpenOrderCard from '../PerpsOpenOrderCard'; import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger'; import Engine from '../../../../../core/Engine'; import { getOrderDirection } from '../../utils/orderUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import usePerpsToasts from '../../hooks/usePerpsToasts'; import Routes from '../../../../../constants/navigation/Routes'; @@ -553,6 +555,14 @@ const PerpsMarketTabs: React.FC = ({ const result = await controller.cancelOrder({ orderId: orderToCancel.orderId, symbol: orderToCancel.symbol, + trackingData: { + totalFee: 0, + marketPrice: currentPrice, + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ...toPerpsEntryAttribution({ + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + }), + }, }); // Order cancellation successful @@ -622,6 +632,7 @@ const PerpsMarketTabs: React.FC = ({ }, [ position?.size, + currentPrice, showToast, PerpsToastOptions.orderManagement.shared, onOrderCancelled, diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.test.tsx b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.test.tsx index bcbc1c79af9..422e80b1dc2 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.test.tsx +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.test.tsx @@ -10,6 +10,11 @@ jest.mock('../../selectors/perpsController', () => ({ selectPerpsEligibility: jest.fn(), })); +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + // Mock react-redux jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), diff --git a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx index 0540a04f7f7..62ef8adc809 100644 --- a/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx +++ b/app/components/UI/Perps/components/PerpsOpenOrderCard/PerpsOpenOrderCard.tsx @@ -39,11 +39,8 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '@metamask/perps-controller'; -import { - MetaMetricsEvents, - mergeAssetViewedProperties, -} from '../../../../../core/Analytics'; -import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; /** * PerpsOpenOrderCard Component @@ -83,7 +80,7 @@ const PerpsOpenOrderCard: React.FC = ({ isCancelling = false, }) => { const { styles } = useStyles(styleSheet, {}); - const { trackEvent, createEventBuilder } = useAnalytics(); + const { track } = usePerpsEventTracking(); // Used to prevent rapid clicks on the cancel button before it has time to re-render. const isLocallyCancellingRef = useRef(false); @@ -145,24 +142,14 @@ const PerpsOpenOrderCard: React.FC = ({ } if (!isEligible) { - // Track geo-block screen viewed - const geoBlockProperties = { + // Track geo-block screen viewed. Routed through usePerpsEventTracking so + // UTM attribution is merged and the companion Asset Viewed is emitted + // consistently with every other Perps screen-view. + track(MetaMetricsEvents.PERPS_SCREEN_VIEWED, { [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.GEO_BLOCK_NOTIF, [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.CANCEL_ORDER, - }; - trackEvent( - createEventBuilder(MetaMetricsEvents.PERPS_SCREEN_VIEWED) - .addProperties(geoBlockProperties) - .build(), - ); - trackEvent( - createEventBuilder(MetaMetricsEvents.ASSET_VIEWED) - .addProperties( - mergeAssetViewedProperties('Perps', geoBlockProperties), - ) - .build(), - ); + }); setIsEligibilityModalVisible(true); return; } @@ -175,7 +162,7 @@ const PerpsOpenOrderCard: React.FC = ({ }); onCancel?.(order); - }, [isEligible, onCancel, order, trackEvent, createEventBuilder]); + }, [isEligible, onCancel, order, track]); const handleCardPress = useCallback(() => { if (onSelect) { diff --git a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts new file mode 100644 index 00000000000..d31d89711e2 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts @@ -0,0 +1,134 @@ +import { renderHook } from '@testing-library/react-native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { PERPS_EVENT_PROPERTY } from '@metamask/perps-controller'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { usePerpsEventTracking } from './usePerpsEventTracking'; +import { usePerpsAbandonOrderTracking } from './usePerpsAbandonOrderTracking'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn(), + useFocusEffect: jest.fn((callback) => callback()), +})); +jest.mock('./usePerpsEventTracking'); + +const mockTrack = jest.fn(); + +interface MockNav { + addListener: jest.Mock; + getState: jest.Mock; + getParent: jest.Mock; + listeners: Record void)[]>; + routes: unknown[]; +} + +const createMockNavigation = (): MockNav => { + const nav: MockNav = { + listeners: {}, + routes: [{ key: 'trade' }], + addListener: jest.fn(), + getState: jest.fn(), + getParent: jest.fn(() => undefined), + }; + nav.addListener.mockImplementation((event: string, cb: () => void) => { + (nav.listeners[event] ||= []).push(cb); + return jest.fn(); + }); + nav.getState.mockImplementation(() => ({ routes: nav.routes })); + return nav; +}; + +const fire = (nav: MockNav, event: string) => { + (nav.listeners[event] || []).forEach((cb) => cb()); +}; + +describe('usePerpsAbandonOrderTracking', () => { + let nav: MockNav; + + const renderTracking = (hasCommitted = false) => { + const hasCommittedRef = { current: hasCommitted }; + const getAbandonProperties = () => ({ + [PERPS_EVENT_PROPERTY.ASSET]: 'BTC', + [PERPS_EVENT_PROPERTY.ACTION]: 'abandon_order', + }); + const result = renderHook(() => + usePerpsAbandonOrderTracking({ getAbandonProperties, hasCommittedRef }), + ); + return { ...result, hasCommittedRef }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(1000); + nav = createMockNavigation(); + (useNavigation as jest.Mock).mockReturnValue(nav); + (useFocusEffect as jest.Mock).mockImplementation((cb) => cb()); + jest.mocked(usePerpsEventTracking).mockReturnValue({ + track: mockTrack, + } as unknown as ReturnType); + }); + + afterEach(() => { + // Restores Date.now (and any other spies) so the pinned timestamp + // doesn't leak into other test files in the same worker. + jest.restoreAllMocks(); + }); + + it('emits abandon_order on beforeRemove (back / hardware back)', () => { + renderTracking(); + fire(nav, 'beforeRemove'); + + expect(mockTrack).toHaveBeenCalledTimes(1); + expect(mockTrack).toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_UI_INTERACTION, + expect.objectContaining({ + [PERPS_EVENT_PROPERTY.ASSET]: 'BTC', + [PERPS_EVENT_PROPERTY.TIME_ON_SCREEN_MS]: 0, + }), + ); + }); + + it('emits abandon_order on blur when navigation depth is unchanged (tab away)', () => { + renderTracking(); + // Depth unchanged since focus → genuine tab switch. + fire(nav, 'blur'); + + expect(mockTrack).toHaveBeenCalledTimes(1); + }); + + it('does NOT emit on blur when a child route was pushed (depth increased)', () => { + renderTracking(); + // A child route (TP/SL sheet, cross-margin modal, payment selector) pushed. + nav.routes = [{ key: 'trade' }, { key: 'child' }]; + fire(nav, 'blur'); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('does NOT emit after the flow is committed (placed / confirmed)', () => { + const { hasCommittedRef } = renderTracking(); + // Commit happens during the focused session. + hasCommittedRef.current = true; + fire(nav, 'beforeRemove'); + fire(nav, 'blur'); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + + it('clears a committed flag from a prior session on focus so the next session still tracks abandon', () => { + // A stale commit carried in at focus time (reused, non-remounted screen) + // must not suppress the fresh session — focus resets it. + const { hasCommittedRef } = renderTracking(true); + expect(hasCommittedRef.current).toBe(false); + + fire(nav, 'beforeRemove'); + expect(mockTrack).toHaveBeenCalledTimes(1); + }); + + it('emits at most once across overlapping beforeRemove and blur', () => { + renderTracking(); + fire(nav, 'beforeRemove'); + fire(nav, 'blur'); + + expect(mockTrack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts new file mode 100644 index 00000000000..fafb6f787bc --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { PERPS_EVENT_PROPERTY } from '@metamask/perps-controller'; +import { usePerpsEventTracking } from './usePerpsEventTracking'; + +interface NavigationDepthNode { + getState?: () => { routes?: unknown[] } | undefined; + getParent?: () => NavigationDepthNode | undefined; +} + +/** + * Total number of routes across this screen's navigator chain up to the root. + * + * A pushed child route (same stack or a root modal) increases this count, a pop + * decreases it, and a tab switch leaves it unchanged (only an index changes). + * This lets abandon tracking tell a genuine tab-away / backgrounding apart from + * internal navigation to a child sheet/modal. + */ +function getNavigationStackDepth(navigation: NavigationDepthNode): number { + let depth = 0; + let current: NavigationDepthNode | undefined = navigation; + while (current) { + const routes = current.getState?.()?.routes; + if (Array.isArray(routes)) { + depth += routes.length; + } + current = current.getParent?.(); + } + return depth; +} + +/** + * Emit an `abandon_order` PERPS_UI_INTERACTION when the user leaves a trade + * screen without committing. + * + * Fires on a real exit — back swipe, hardware back, programmatic dismissal + * (`beforeRemove`) — and on a genuine tab switch away (a `blur` where the + * navigation depth is unchanged). It does NOT fire when a child route is pushed + * on top (TP/SL screen, cross-margin modal, payment-token selector: depth + * increases) nor after the caller marks the flow committed via `hasCommittedRef`. + * A one-shot guard (reset on focus) prevents double emission from overlapping + * `beforeRemove`/`blur` events. + */ +export function usePerpsAbandonOrderTracking({ + getAbandonProperties, + hasCommittedRef, +}: { + getAbandonProperties: () => Record; + hasCommittedRef: MutableRefObject; +}): void { + const navigation = useNavigation(); + const { track } = usePerpsEventTracking(); + const focusStartRef = useRef(0); + const focusDepthRef = useRef(0); + const emittedRef = useRef(false); + + const emitAbandon = useCallback(() => { + if (hasCommittedRef.current || emittedRef.current) { + return; + } + emittedRef.current = true; + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + ...getAbandonProperties(), + [PERPS_EVENT_PROPERTY.TIME_ON_SCREEN_MS]: + Date.now() - focusStartRef.current, + }); + }, [getAbandonProperties, hasCommittedRef, track]); + + useFocusEffect( + useCallback(() => { + focusStartRef.current = Date.now(); + focusDepthRef.current = getNavigationStackDepth(navigation); + emittedRef.current = false; + // A new focus is a fresh order session: clear the committed flag so a + // commit from a previous session (on a reused, non-remounted screen) does + // not suppress abandon tracking for this one. Safe when the screen does + // remount too — the ref simply starts false either way. + hasCommittedRef.current = false; + }, [navigation, hasCommittedRef]), + ); + + useEffect(() => { + const onBeforeRemove = () => emitAbandon(); + const onBlur = () => { + // Unchanged depth => tab switch / backgrounding (a push increases it, a + // pop decreases it), which is also an abandonment. + if (getNavigationStackDepth(navigation) === focusDepthRef.current) { + emitAbandon(); + } + }; + const unsubscribeRemove = navigation.addListener( + 'beforeRemove', + onBeforeRemove, + ); + const unsubscribeBlur = navigation.addListener('blur', onBlur); + return () => { + unsubscribeRemove(); + unsubscribeBlur(); + }; + }, [navigation, emitAbandon]); +} diff --git a/app/components/UI/Perps/hooks/usePerpsEventTracking.test.ts b/app/components/UI/Perps/hooks/usePerpsEventTracking.test.ts index 3a351636448..961ba96bf4e 100644 --- a/app/components/UI/Perps/hooks/usePerpsEventTracking.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsEventTracking.test.ts @@ -6,11 +6,19 @@ import { } from '@metamask/perps-controller'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { getPerpsUtmAttributionProperties } from '../utils/perpsAnalyticsAttribution'; const mockTrackEvent = jest.fn(); const mockCreateEventBuilder = jest.fn(); jest.mock('../../../hooks/useAnalytics/useAnalytics'); +jest.mock('../utils/perpsAnalyticsAttribution', () => ({ + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + +const mockGetPerpsUtmAttributionProperties = jest.mocked( + getPerpsUtmAttributionProperties, +); describe('usePerpsEventTracking', () => { beforeEach(() => { @@ -113,6 +121,52 @@ describe('usePerpsEventTracking', () => { ); }); + it('merges UTM attribution into PERPS_SCREEN_VIEWED props', () => { + mockGetPerpsUtmAttributionProperties.mockReturnValueOnce({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'newsletter', + [PERPS_EVENT_PROPERTY.UTM_MEDIUM]: 'email', + }); + const { result } = renderHook(() => usePerpsEventTracking()); + const customProps = { + screen_type: 'home', + // Explicit UTM should win over the stored attribution value. + [PERPS_EVENT_PROPERTY.UTM_MEDIUM]: 'push', + }; + + act(() => { + result.current.track( + MetaMetricsEvents.PERPS_SCREEN_VIEWED, + customProps, + ); + }); + + const perpsBuilder = mockCreateEventBuilder.mock.results[0].value; + expect(perpsBuilder.addProperties).toHaveBeenCalledWith({ + [PERPS_EVENT_PROPERTY.UTM_SOURCE]: 'newsletter', + [PERPS_EVENT_PROPERTY.TIMESTAMP]: 1234567890, + ...customProps, + }); + }); + + it('does not merge UTM attribution into non-screen-viewed events', () => { + const { result } = renderHook(() => usePerpsEventTracking()); + + act(() => { + result.current.track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + }); + }); + + expect(mockGetPerpsUtmAttributionProperties).not.toHaveBeenCalled(); + const builder = mockCreateEventBuilder.mock.results[0].value; + expect(builder.addProperties).toHaveBeenCalledWith({ + [PERPS_EVENT_PROPERTY.TIMESTAMP]: 1234567890, + [PERPS_EVENT_PROPERTY.INTERACTION_TYPE]: + PERPS_EVENT_VALUE.INTERACTION_TYPE.TAP, + }); + }); + it('does not track Asset Viewed for cancel_all_orders', () => { const { result } = renderHook(() => usePerpsEventTracking()); const customProps = { diff --git a/app/components/UI/Perps/hooks/usePerpsEventTracking.ts b/app/components/UI/Perps/hooks/usePerpsEventTracking.ts index 6b4ed0c3bc7..e55d613d98a 100644 --- a/app/components/UI/Perps/hooks/usePerpsEventTracking.ts +++ b/app/components/UI/Perps/hooks/usePerpsEventTracking.ts @@ -8,6 +8,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from '@metamask/perps-controller'; +import { getPerpsUtmAttributionProperties } from '../utils/perpsAnalyticsAttribution'; // Static helper function - moved outside component to avoid recreation const allTrue = (conditionArray: boolean[]): boolean => @@ -91,12 +92,22 @@ export const usePerpsEventTracking = (options?: EventTrackingOptions) => { [PERPS_EVENT_PROPERTY.TIMESTAMP]: Date.now(), ...properties, }; - trackEvent(createEventBuilder(eventName).addProperties(props).build()); - if ( - eventName === MetaMetricsEvents.PERPS_SCREEN_VIEWED && - shouldEmitAssetViewedForPerpsScreenViewed(props) - ) { + const isScreenViewed = + eventName === MetaMetricsEvents.PERPS_SCREEN_VIEWED; + + // attach stored UTM attribution to every Perp Screen Viewed + // event. Explicit props win over attribution, so it never overrides a + // caller-provided UTM value. Scoped to Screen Viewed only — the mirrored + // Asset Viewed emission below keeps its existing property set. + const emittedProps = isScreenViewed + ? { ...getPerpsUtmAttributionProperties(), ...props } + : props; + trackEvent( + createEventBuilder(eventName).addProperties(emittedProps).build(), + ); + + if (isScreenViewed && shouldEmitAssetViewedForPerpsScreenViewed(props)) { trackEvent( createEventBuilder(MetaMetricsEvents.ASSET_VIEWED) .addProperties(mergeAssetViewedProperties('Perps', props)) diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts index c6b3fa865ea..ca51e8494d5 100644 --- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts +++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts @@ -48,7 +48,11 @@ export interface PerpsNavigationHandlers { params?: PerpsNavigationParamList['PerpsTutorial'], ) => void; navigateToAdjustMargin: (position: Position, mode: 'add' | 'remove') => void; - navigateToClosePosition: (position: Position, source?: string) => void; + navigateToClosePosition: ( + position: Position, + source?: string, + entry?: { buttonClicked?: string; buttonLocation?: string }, + ) => void; navigateToOrderDetails: (order: Order) => void; // Utility navigation @@ -223,8 +227,17 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => { ); const navigateToClosePosition = useCallback( - (position: Position, source?: string) => { - navigation.navigate(Routes.PERPS.CLOSE_POSITION, { position, source }); + ( + position: Position, + source?: string, + entry?: { buttonClicked?: string; buttonLocation?: string }, + ) => { + navigation.navigate(Routes.PERPS.CLOSE_POSITION, { + position, + source, + buttonClicked: entry?.buttonClicked, + buttonLocation: entry?.buttonLocation, + }); }, [navigation], ); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts index 9824a0e4ee3..7d6a55e9362 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts @@ -1,11 +1,5 @@ import { renderHook, act, waitFor } from '@testing-library/react-native'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { - PERPS_EVENT_PROPERTY, - PERPS_EVENT_VALUE, - type OrderParams, - type Position, -} from '@metamask/perps-controller'; +import { type OrderParams, type Position } from '@metamask/perps-controller'; import { usePerpsOrderExecution } from './usePerpsOrderExecution'; import { usePerpsTrading } from './usePerpsTrading'; import { @@ -18,10 +12,6 @@ import { PERPS_CUF_STREAM_TIMEOUT_MS, } from '../constants/perpsCufTags'; import { endTrace, TraceName } from '../../../../util/trace'; -import { - PERPS_EVENT_PROPERTY as PERPS_CHART_EVENT_PROPERTY, - PERPS_EVENT_VALUE as PERPS_CHART_EVENT_VALUE, -} from '@metamask/perps-controller/constants'; jest.mock('./usePerpsTrading'); jest.mock('../../../../util/trace', () => { @@ -49,10 +39,6 @@ jest.mock('../providers/PerpsStreamManager', () => ({ }, }), })); -const mockTrack = jest.fn(); -jest.mock('./usePerpsEventTracking', () => ({ - usePerpsEventTracking: () => ({ track: mockTrack }), -})); jest.mock('./usePerpsMeasurement', () => ({ usePerpsMeasurement: jest.fn(), })); @@ -81,7 +67,6 @@ describe('usePerpsOrderExecution', () => { // Clear the CUF singleton's pending map/place-order state so armed ops from // one test can't leak into the next (these tests drive the real module). resetPerpsCufTraceForTests(); - mockTrack.mockClear(); mockEndTrace.mockClear(); mockGetPositionsSnapshot.mockReturnValue([]); // Loaded, no positions by default mockGetOrdersSnapshot.mockReturnValue([]); // Loaded, no orders by default @@ -328,6 +313,15 @@ describe('usePerpsOrderExecution', () => { handlePerpsCufOrdersDelivered([{ orderId: 'lim1' }]); }); + expect(mockEndTrace).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringContaining( + TraceName.PerpsPlaceLimitOrderToOrderRendered, + ), + data: expect.objectContaining({ success: true }), + }), + ); + // Flush the scheduled fallback so no timer leaks past the test. act(() => { jest.runOnlyPendingTimers(); @@ -554,7 +548,7 @@ describe('usePerpsOrderExecution', () => { }); }); - it('tracks partially filled event with trackingData when filledSize is between 0 and order size', async () => { + it('forwards trackingData to controller on partial fill without client trade analytics', async () => { const onSuccess = jest.fn(); const paramsWithTracking: OrderParams = { ...mockOrderParams, @@ -565,9 +559,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -585,55 +576,8 @@ describe('usePerpsOrderExecution', () => { expect(result.current.isPlacing).toBe(false); }); - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: - PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: true, - [PERPS_CHART_EVENT_PROPERTY.CHART_LIBRARY]: 'advanced', - [PERPS_CHART_EVENT_PROPERTY.ASSET_TYPE]: - PERPS_CHART_EVENT_VALUE.ASSET_TYPE.PERP, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: 'USDC', - [PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED]: 'ethereum', - }), - ); - }); - - it('tracks success with mm_pay_token_selected Perps Balance when trackingData has tradeWithToken false', async () => { - const onSuccess = jest.fn(); - const paramsWithPerpsBalance: OrderParams = { - ...mockOrderParams, - size: '0.2', - trackingData: { - totalFee: 0, - marketPrice: 50000, - tradeWithToken: false, - }, - }; - - mockPlaceOrderSuccessWithRender({ filledSize: '0.1' }); - - const { result } = renderHook(() => - usePerpsOrderExecution({ onSuccess, onError: jest.fn() }), - ); - - await act(async () => { - await result.current.placeOrder(paramsWithPerpsBalance); - }); - - await waitFor(() => { - expect(result.current.isPlacing).toBe(false); - }); - - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE, - }), - ); + expect(onSuccess).toHaveBeenCalledWith(mockPosition); + expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); }); }); @@ -694,7 +638,7 @@ describe('usePerpsOrderExecution', () => { ); }); - it('tracks failed order with trade_with_token and mm_pay fields when trackingData is set', async () => { + it('forwards trackingData to controller on failed order without client trade analytics', async () => { const onError = jest.fn(); const paramsWithTracking: OrderParams = { ...mockOrderParams, @@ -704,9 +648,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -725,55 +666,8 @@ describe('usePerpsOrderExecution', () => { expect(result.current.isPlacing).toBe(false); }); - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: true, - [PERPS_CHART_EVENT_PROPERTY.CHART_LIBRARY]: 'advanced', - [PERPS_CHART_EVENT_PROPERTY.ASSET_TYPE]: - PERPS_CHART_EVENT_VALUE.ASSET_TYPE.PERP, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: 'USDC', - [PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED]: 'ethereum', - }), - ); - }); - - it('tracks failed order with mm_pay_token_selected Perps Balance when trackingData has tradeWithToken false', async () => { - const onError = jest.fn(); - const paramsWithPerpsBalance: OrderParams = { - ...mockOrderParams, - trackingData: { - totalFee: 0, - marketPrice: 50000, - tradeWithToken: false, - }, - }; - - mockPlaceOrder.mockResolvedValue({ - success: false, - error: 'Insufficient margin', - }); - - const { result } = renderHook(() => usePerpsOrderExecution({ onError })); - - await act(async () => { - await result.current.placeOrder(paramsWithPerpsBalance); - }); - - await waitFor(() => { - expect(result.current.isPlacing).toBe(false); - }); - - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE, - }), - ); + expect(onError).toHaveBeenCalledWith('Insufficient margin'); + expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); }); it('calls onError with unknown error when order returns success false without error', async () => { @@ -816,7 +710,7 @@ describe('usePerpsOrderExecution', () => { expect(result.current.error).toBe('Network timeout'); }); - it('tracks exception with trade_with_token and mm_pay fields when placeOrder rejects and trackingData is set', async () => { + it('forwards trackingData to controller when placeOrder rejects without client trade analytics', async () => { const onError = jest.fn(); const paramsWithTracking: OrderParams = { ...mockOrderParams, @@ -826,9 +720,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -844,52 +735,8 @@ describe('usePerpsOrderExecution', () => { expect(result.current.isPlacing).toBe(false); }); - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: true, - [PERPS_CHART_EVENT_PROPERTY.CHART_LIBRARY]: 'advanced', - [PERPS_CHART_EVENT_PROPERTY.ASSET_TYPE]: - PERPS_CHART_EVENT_VALUE.ASSET_TYPE.PERP, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: 'USDC', - [PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED]: 'ethereum', - }), - ); - }); - - it('tracks exception with mm_pay_token_selected Perps Balance when placeOrder rejects and trackingData has tradeWithToken false', async () => { - const onError = jest.fn(); - const paramsWithPerpsBalance: OrderParams = { - ...mockOrderParams, - trackingData: { - totalFee: 0, - marketPrice: 50000, - tradeWithToken: false, - }, - }; - - mockPlaceOrder.mockRejectedValue(new Error('Network timeout')); - - const { result } = renderHook(() => usePerpsOrderExecution({ onError })); - - await act(async () => { - await result.current.placeOrder(paramsWithPerpsBalance); - }); - - await waitFor(() => { - expect(result.current.isPlacing).toBe(false); - }); - - expect(mockTrack).toHaveBeenCalledWith( - MetaMetricsEvents.PERPS_TRADE_TRANSACTION, - expect.objectContaining({ - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: false, - [PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED]: - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE, - }), - ); + expect(onError).toHaveBeenCalledWith('Network timeout'); + expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index 465ad9cf66e..27ee7fa8a2e 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -2,16 +2,15 @@ import { useCallback, useState } from 'react'; import { strings } from '../../../../../locales/i18n'; import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; import { TraceName, TraceOperation } from '../../../../util/trace'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; import Logger from '../../../../util/Logger'; import { ensureError } from '../../../../util/errorUtils'; import { PERPS_CONSTANTS, + PERPS_EVENT_VALUE, type OrderParams, type OrderResult, type Position, } from '@metamask/perps-controller'; -import { usePerpsEventTracking } from './usePerpsEventTracking'; import { usePerpsMeasurement } from './usePerpsMeasurement'; import { usePerpsTrading } from './usePerpsTrading'; import { @@ -32,14 +31,6 @@ import { PERPS_CUF_STREAM_CONFIRM_RACE_MS, } from '../constants/perpsCufTags'; import { usePerpsStream } from '../providers/PerpsStreamManager'; -import { - PERPS_EVENT_PROPERTY, - PERPS_EVENT_VALUE, -} from '@metamask/perps-controller/constants'; - -interface PerpsChartTrackingData { - chartLibrary?: string; -} interface UsePerpsOrderExecutionParams { /** Called when the order has been successfully submitted to the exchange. */ @@ -66,14 +57,23 @@ const getPerpsOrderPositionSnapshot = ( /** * Hook to handle order execution flow * Manages loading states, success/error handling, and stream-confirmed - * position rendering + * position rendering. + * + * Trade transaction analytics (submitted + terminal) are emitted by + * `@metamask/perps-controller` TradingService — do not re-emit + * PERPS_TRADE_TRANSACTION from this hook. + * + * Partial-fill status on the open-trade path (PARTIALLY_FILLED plus + * amount_filled / remaining_amount) is likewise controller-owned and lands with + * the next `@metamask/perps-controller` release — do not emit it here, as client + * emission would double-count once the controller ships it (same deferral as + * `orderExecutionLatencyMs`). */ export function usePerpsOrderExecution( params: UsePerpsOrderExecutionParams = {}, ): UsePerpsOrderExecutionReturn { const { onSubmitted, onSuccess, onError } = params; const { placeOrder: controllerPlaceOrder } = usePerpsTrading(); - const { track } = usePerpsEventTracking(); const stream = usePerpsStream(); const [isPlacing, setIsPlacing] = useState(false); @@ -178,17 +178,6 @@ export function usePerpsOrderExecution( } }, PERPS_CUF_STREAM_TIMEOUT_MS); - const chartLibrary = ( - orderParams.trackingData as PerpsChartTrackingData | undefined - )?.chartLibrary; - const chartAnalyticsProperties = chartLibrary - ? { - [PERPS_EVENT_PROPERTY.CHART_LIBRARY]: chartLibrary, - [PERPS_EVENT_PROPERTY.ASSET_TYPE]: - PERPS_EVENT_VALUE.ASSET_TYPE.PERP, - } - : undefined; - try { setIsPlacing(true); setError(undefined); @@ -211,53 +200,6 @@ export function usePerpsOrderExecution( result, ); - // Check if order was partially filled - const orderSize = Number.parseFloat(orderParams.size.toString()); - const filledSize = result.filledSize - ? Number.parseFloat(result.filledSize) - : orderSize; - const isPartiallyFilled = filledSize > 0 && filledSize < orderSize; - - if (isPartiallyFilled) { - // Track partially filled event - const partialProps: Record = { - [PERPS_EVENT_PROPERTY.STATUS]: - PERPS_EVENT_VALUE.STATUS.PARTIALLY_FILLED, - [PERPS_EVENT_PROPERTY.ASSET]: orderParams.symbol, - [PERPS_EVENT_PROPERTY.DIRECTION]: orderParams.isBuy - ? PERPS_EVENT_VALUE.DIRECTION.LONG - : PERPS_EVENT_VALUE.DIRECTION.SHORT, - [PERPS_EVENT_PROPERTY.LEVERAGE]: orderParams.leverage || 1, - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderSize, - [PERPS_EVENT_PROPERTY.ORDER_TYPE]: orderParams.orderType, - [PERPS_EVENT_PROPERTY.AMOUNT_FILLED]: filledSize, - [PERPS_EVENT_PROPERTY.REMAINING_AMOUNT]: orderSize - filledSize, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: - orderParams.trackingData?.tradeWithToken === true, - }; - if (orderParams.trackingData?.source) { - partialProps[PERPS_EVENT_PROPERTY.SOURCE] = - orderParams.trackingData.source; - } - if (chartAnalyticsProperties) { - Object.assign(partialProps, chartAnalyticsProperties); - } - if (orderParams.trackingData?.tradeWithToken === true) { - if (orderParams.trackingData.mmPayTokenSelected != null) { - partialProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - orderParams.trackingData.mmPayTokenSelected; - } - if (orderParams.trackingData.mmPayNetworkSelected != null) { - partialProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] = - orderParams.trackingData.mmPayNetworkSelected; - } - } else if (orderParams.trackingData !== undefined) { - partialProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; - } - track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, partialProps); - } - if (!isMarketOrder) { // Resting limit order: accepted, no position renders now. Confirm // immediately, then end the order-render CUF when the resting @@ -375,41 +317,6 @@ export function usePerpsOrderExecution( setError(errorMessage); DevLogger.log('usePerpsOrderExecution: Order failed', errorMessage); - // Track order failure with specific event - const failedProps: Record = { - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.ASSET]: orderParams.symbol, - [PERPS_EVENT_PROPERTY.DIRECTION]: orderParams.isBuy - ? PERPS_EVENT_VALUE.DIRECTION.LONG - : PERPS_EVENT_VALUE.DIRECTION.SHORT, - [PERPS_EVENT_PROPERTY.ORDER_TYPE]: orderParams.orderType, - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderParams.size, - [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: - orderParams.trackingData?.tradeWithToken === true, - }; - if (orderParams.trackingData?.source) { - failedProps[PERPS_EVENT_PROPERTY.SOURCE] = - orderParams.trackingData.source; - } - if (chartAnalyticsProperties) { - Object.assign(failedProps, chartAnalyticsProperties); - } - if (orderParams.trackingData?.tradeWithToken === true) { - if (orderParams.trackingData.mmPayTokenSelected != null) { - failedProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - orderParams.trackingData.mmPayTokenSelected; - } - if (orderParams.trackingData.mmPayNetworkSelected != null) { - failedProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] = - orderParams.trackingData.mmPayNetworkSelected; - } - } else if (orderParams.trackingData !== undefined) { - failedProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; - } - track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, failedProps); - onError?.(errorMessage); } } catch (err) { @@ -451,47 +358,12 @@ export function usePerpsOrderExecution( }, }); - // Track exception with specific event - const exceptionProps: Record = { - [PERPS_EVENT_PROPERTY.STATUS]: PERPS_EVENT_VALUE.STATUS.FAILED, - [PERPS_EVENT_PROPERTY.ASSET]: orderParams.symbol, - [PERPS_EVENT_PROPERTY.DIRECTION]: orderParams.isBuy - ? PERPS_EVENT_VALUE.DIRECTION.LONG - : PERPS_EVENT_VALUE.DIRECTION.SHORT, - [PERPS_EVENT_PROPERTY.ORDER_TYPE]: orderParams.orderType, - [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderParams.size, - [PERPS_EVENT_PROPERTY.ERROR_MESSAGE]: errorMessage, - [PERPS_EVENT_PROPERTY.TRADE_WITH_TOKEN]: - orderParams.trackingData?.tradeWithToken === true, - }; - if (orderParams.trackingData?.source) { - exceptionProps[PERPS_EVENT_PROPERTY.SOURCE] = - orderParams.trackingData.source; - } - if (chartAnalyticsProperties) { - Object.assign(exceptionProps, chartAnalyticsProperties); - } - if (orderParams.trackingData?.tradeWithToken === true) { - if (orderParams.trackingData.mmPayTokenSelected != null) { - exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - orderParams.trackingData.mmPayTokenSelected; - } - if (orderParams.trackingData.mmPayNetworkSelected != null) { - exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_NETWORK_SELECTED] = - orderParams.trackingData.mmPayNetworkSelected; - } - } else if (orderParams.trackingData !== undefined) { - exceptionProps[PERPS_EVENT_PROPERTY.MM_PAY_TOKEN_SELECTED] = - PERPS_EVENT_VALUE.MM_PAY_TOKEN.PERPS_BALANCE; - } - track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION, exceptionProps); - onError?.(errorMessage); } finally { setIsPlacing(false); } }, - [controllerPlaceOrder, stream, onSubmitted, onSuccess, onError, track], + [controllerPlaceOrder, stream, onSubmitted, onSuccess, onError], ); return { diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 4eecfb1c1e0..49287a0ede0 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -61,6 +61,8 @@ export type PerpsStackParamList = { showPerpsHeader?: boolean; /** Analytics: how the user got to the order screen (e.g. trade_action, order_book_long_button, asset_detail_screen) */ source?: string; + /** Analytics: market-list discovery section (search, watchlist, category, all_markets) */ + source_section?: string; /** Analytics: chart library active when the order flow started */ chartLibrary?: string; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; @@ -145,6 +147,8 @@ export type PerpsStackParamList = { PerpsClosePosition: { position: Position; source?: string; + buttonClicked?: string; + buttonLocation?: string; }; PerpsAdjustMargin: { diff --git a/app/components/UI/Perps/utils/deriveTradeAction.test.ts b/app/components/UI/Perps/utils/deriveTradeAction.test.ts new file mode 100644 index 00000000000..9efa96a5f03 --- /dev/null +++ b/app/components/UI/Perps/utils/deriveTradeAction.test.ts @@ -0,0 +1,42 @@ +import { PERPS_EVENT_VALUE, type Position } from '@metamask/perps-controller'; +import { derivePerpsTradeAction } from './deriveTradeAction'; + +const positionWithSize = (size: string): Pick => ({ size }); + +describe('derivePerpsTradeAction', () => { + it('returns create_position when there is no existing position', () => { + expect(derivePerpsTradeAction(null, 'long')).toBe( + PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + ); + expect(derivePerpsTradeAction(undefined, 'short')).toBe( + PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + ); + }); + + it('returns create_position when the existing position size is zero', () => { + expect(derivePerpsTradeAction(positionWithSize('0'), 'long')).toBe( + PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + ); + }); + + it('returns increase_exposure when the order is the same direction as the position', () => { + expect(derivePerpsTradeAction(positionWithSize('1.5'), 'long')).toBe( + PERPS_EVENT_VALUE.ACTION.INCREASE_EXPOSURE, + ); + expect(derivePerpsTradeAction(positionWithSize('-1.5'), 'short')).toBe( + PERPS_EVENT_VALUE.ACTION.INCREASE_EXPOSURE, + ); + }); + + it('returns flip_long_to_short when a long position is reversed by a short order', () => { + expect(derivePerpsTradeAction(positionWithSize('2'), 'short')).toBe( + PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT, + ); + }); + + it('returns flip_short_to_long when a short position is reversed by a long order', () => { + expect(derivePerpsTradeAction(positionWithSize('-2'), 'long')).toBe( + PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG, + ); + }); +}); diff --git a/app/components/UI/Perps/utils/deriveTradeAction.ts b/app/components/UI/Perps/utils/deriveTradeAction.ts new file mode 100644 index 00000000000..57e9b8f7bca --- /dev/null +++ b/app/components/UI/Perps/utils/deriveTradeAction.ts @@ -0,0 +1,43 @@ +import { PERPS_EVENT_VALUE, type Position } from '@metamask/perps-controller'; +import { getPositionDirection } from './positionCalculations'; + +export type PerpsTradeAction = + | typeof PERPS_EVENT_VALUE.ACTION.CREATE_POSITION + | typeof PERPS_EVENT_VALUE.ACTION.INCREASE_EXPOSURE + | typeof PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + | typeof PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; + +/** + * Derive the perps place-order trade action from the existing position (null + * when flat) and the incoming order direction. + * + * - no position -> create_position + * - same direction -> increase_exposure + * - opposite direction -> flip_long_to_short / flip_short_to_long + * + * The controller forwards this verbatim as the transaction `action` property + * (via `trackingData.tradeAction`), and the PERPS_TRANSACTION_CONSIDERED event + * uses the same derivation so the considered and executed events agree. + * + * @param existingPosition - The existing position, or null/undefined when flat. + * @param orderDirection - The incoming order direction. + * @returns The derived trade action. + */ +export function derivePerpsTradeAction( + existingPosition: Pick | null | undefined, + orderDirection: 'long' | 'short', +): PerpsTradeAction { + const positionDirection = existingPosition + ? getPositionDirection(existingPosition.size) + : 'unknown'; + + if (positionDirection === 'unknown') { + return PERPS_EVENT_VALUE.ACTION.CREATE_POSITION; + } + if (positionDirection === orderDirection) { + return PERPS_EVENT_VALUE.ACTION.INCREASE_EXPOSURE; + } + return positionDirection === 'long' + ? PERPS_EVENT_VALUE.ACTION.FLIP_LONG_TO_SHORT + : PERPS_EVENT_VALUE.ACTION.FLIP_SHORT_TO_LONG; +} diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts new file mode 100644 index 00000000000..2deab5ad655 --- /dev/null +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts @@ -0,0 +1,123 @@ +import { + getPerpsUtmAttributionProperties, + hasPerpsUtmAttribution, + parsePerpsUtmFromPath, + setPerpsUtmAttribution, + toPerpsEntryAttribution, +} from './perpsAnalyticsAttribution'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; + +const mockSetAttributionContext = jest.fn(); +const mockMergeAttributionContext = jest.fn(); + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + PerpsController: { + setAttributionContext: (...args: unknown[]) => + mockSetAttributionContext(...args), + mergeAttributionContext: (...args: unknown[]) => + mockMergeAttributionContext(...args), + }, + }, + }, +})); + +jest.mock('../../../../core/SDKConnect/utils/DevLogger', () => ({ + __esModule: true, + default: { log: jest.fn() }, +})); + +describe('perpsAnalyticsAttribution', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('toPerpsEntryAttribution', () => { + it('maps source to entryPoint', () => { + expect(toPerpsEntryAttribution({ source: 'deeplink' })).toEqual({ + entryPoint: 'deeplink', + }); + }); + + it('maps sourceSection to discovery fields', () => { + expect( + toPerpsEntryAttribution({ + source: 'perps_home', + sourceSection: 'watchlist', + }), + ).toEqual({ + entryPoint: 'perps_home', + discoverySource: 'watchlist', + perpDiscoverySource: 'watchlist', + }); + }); + + it('returns empty object when no input', () => { + expect(toPerpsEntryAttribution({})).toEqual({}); + }); + }); + + describe('parsePerpsUtmFromPath', () => { + it('parses utm params from query string', () => { + expect( + parsePerpsUtmFromPath( + 'perps?screen=home&utm_source=twitter&utm_medium=social&utm_campaign=launch', + ), + ).toEqual({ + utmSource: 'twitter', + utmMedium: 'social', + utmCampaign: 'launch', + }); + }); + + it('returns empty object when no utm params', () => { + expect(parsePerpsUtmFromPath('perps?screen=home')).toEqual({}); + }); + }); + + describe('hasPerpsUtmAttribution', () => { + it('returns true when any utm field is set', () => { + expect(hasPerpsUtmAttribution({ utmSource: 'x' })).toBe(true); + }); + + it('returns false when empty', () => { + expect(hasPerpsUtmAttribution({})).toBe(false); + }); + }); + + describe('setPerpsUtmAttribution', () => { + it('calls controller setAttributionContext when utm present', () => { + setPerpsUtmAttribution({ utmSource: 'twitter' }); + expect(mockSetAttributionContext).toHaveBeenCalledWith({ + utmSource: 'twitter', + }); + }); + + it('skips controller call when no utm fields', () => { + setPerpsUtmAttribution({}); + expect(mockSetAttributionContext).not.toHaveBeenCalled(); + }); + }); + + describe('getPerpsUtmAttributionProperties', () => { + it('returns the controller merged attribution props', () => { + mockMergeAttributionContext.mockReturnValue({ utm_source: 'twitter' }); + expect(getPerpsUtmAttributionProperties()).toEqual({ + utm_source: 'twitter', + }); + }); + + it('returns {} and logs instead of throwing when the lookup fails', () => { + mockMergeAttributionContext.mockImplementation(() => { + throw new Error('controller unavailable'); + }); + // Best-effort: the caller (every PERPS_SCREEN_VIEWED build) must still get + // a usable object so the screen-view emit is never taken down. + expect(() => getPerpsUtmAttributionProperties()).not.toThrow(); + expect(getPerpsUtmAttributionProperties()).toEqual({}); + expect(DevLogger.log).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts new file mode 100644 index 00000000000..69a51a86629 --- /dev/null +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts @@ -0,0 +1,115 @@ +/** + * Helpers for mapping Mobile navigation / deeplink context onto the + * perps-controller analytics attribution contract. + */ + +import type { + PerpsAnalyticsProperties, + PerpsAttributionContext, + TrackingData, +} from '@metamask/perps-controller'; +import Engine from '../../../../core/Engine'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; + +export interface PerpsEntryAttributionInput { + source?: string; + sourceSection?: string; +} + +export type PerpsEntryAttribution = Pick< + TrackingData, + 'entryPoint' | 'discoverySource' | 'perpDiscoverySource' +>; + +/** + * Map navigation `source` / `source_section` onto TrackingData attribution fields. + * Keeps legacy `source` callers intact — callers should still pass `source` when needed. + */ +export function toPerpsEntryAttribution( + input: PerpsEntryAttributionInput, +): PerpsEntryAttribution { + const attribution: PerpsEntryAttribution = {}; + if (input.source) { + attribution.entryPoint = input.source; + } + if (input.sourceSection) { + attribution.discoverySource = input.sourceSection; + attribution.perpDiscoverySource = input.sourceSection; + } + return attribution; +} + +/** + * Parse UTM query params from a perps deeplink path (query string or full path). + */ +export function parsePerpsUtmFromPath( + perpsPath: string, +): PerpsAttributionContext { + const query = perpsPath.includes('?') ? perpsPath.split('?')[1] : perpsPath; + const params = new URLSearchParams(query); + + const context: PerpsAttributionContext = {}; + const utmSource = params.get('utm_source') ?? undefined; + const utmMedium = params.get('utm_medium') ?? undefined; + const utmCampaign = params.get('utm_campaign') ?? undefined; + const utmContent = params.get('utm_content') ?? undefined; + const utmTerm = params.get('utm_term') ?? undefined; + + if (utmSource) context.utmSource = utmSource; + if (utmMedium) context.utmMedium = utmMedium; + if (utmCampaign) context.utmCampaign = utmCampaign; + if (utmContent) context.utmContent = utmContent; + if (utmTerm) context.utmTerm = utmTerm; + + return context; +} + +export function hasPerpsUtmAttribution( + context: PerpsAttributionContext, +): boolean { + return Boolean( + context.utmSource || + context.utmMedium || + context.utmCampaign || + context.utmContent || + context.utmTerm, + ); +} + +/** + * Store UTM attribution on the in-memory PerpsController context so + * TradingService can merge it into submitted/terminal events. + * + * The context lives for the app session (not cleared on navigation), so a later + * signed deeplink overwrites earlier UTM — intended last-touch attribution, + * consistent with the extension's session store and the controller's context. + */ +export function setPerpsUtmAttribution(context: PerpsAttributionContext): void { + if (!hasPerpsUtmAttribution(context)) { + return; + } + Engine.context.PerpsController.setAttributionContext(context); +} + +/** + * Snapshot the stored UTM attribution as analytics event properties. + * Delegates to the controller's canonical merge so UTM keys map to their + * PERPS_EVENT_PROPERTY names; returns only the defined UTM keys. + * + * Best-effort by design: this enriches every client PERPS_SCREEN_VIEWED, so a + * throwing Engine/controller lookup must never take down the screen-view emit + * itself. On failure we log and return no UTM props rather than propagate — + * the sanctioned exception to no-swallowed-exceptions (expected, recoverable, + * logged), mirroring the best-effort handling in handlePerpsUrl. + */ +export function getPerpsUtmAttributionProperties(): PerpsAnalyticsProperties { + try { + return Engine.context.PerpsController.mergeAttributionContext(); + } catch (error) { + DevLogger.log( + '[perpsAnalyticsAttribution] UTM attribution lookup failed; emitting without UTM props', + error, + ); + return {}; + } +} diff --git a/app/components/UI/Perps/utils/perpsPaymentTokenSelection.ts b/app/components/UI/Perps/utils/perpsPaymentTokenSelection.ts new file mode 100644 index 00000000000..066e4ff7909 --- /dev/null +++ b/app/components/UI/Perps/utils/perpsPaymentTokenSelection.ts @@ -0,0 +1,29 @@ +/** + * Cross-screen marker for the Perps pay-with-token selector. + * + * The selector sheet is a separate navigation route from PerpsPayRow, and + * re-selecting the already-selected token is a no-op for the pay-token state — + * so token identity alone cannot tell an explicit selection from a dismissal. + * The Perps selector rows call {@link markPerpsPaymentTokenSelection} on press; + * PerpsPayRow resets the marker when it opens the selector and consumes it when + * the screen regains focus, emitting `payment_token_selector_dismissed` only + * when no explicit selection was made. + */ +let selectionMade = false; + +/** Record that a Perps payment-token row was explicitly pressed. */ +export function markPerpsPaymentTokenSelection(): void { + selectionMade = true; +} + +/** Clear any pending selection marker (called when the selector opens). */ +export function resetPerpsPaymentTokenSelection(): void { + selectionMade = false; +} + +/** Read and clear the selection marker. */ +export function consumePerpsPaymentTokenSelection(): boolean { + const made = selectionMade; + selectionMade = false; + return made; +} diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index 9876cd7d943..a55e5697b41 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -31,6 +31,7 @@ import { Hex } from '@metamask/utils'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { EMPTY_ADDRESS } from '../../../../../../constants/transaction'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { markPerpsPaymentTokenSelection } from '../../../../../UI/Perps/utils/perpsPaymentTokenSelection'; import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; import { usePredictBalanceTokenFilter } from '../../../../../UI/Predict/hooks/usePredictBalanceTokenFilter'; @@ -73,6 +74,7 @@ jest.mock('../../../utils/transaction-pay', () => ({ })); jest.mock('../../../../../UI/Perps/hooks/usePerpsPaymentToken'); jest.mock('../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'); +jest.mock('../../../../../UI/Perps/utils/perpsPaymentTokenSelection'); jest.mock('../../../../../UI/Predict/hooks/usePredictPaymentToken'); jest.mock('../../../../../UI/Predict/hooks/usePredictBalanceTokenFilter'); @@ -336,6 +338,9 @@ describe('PayWithModal', () => { chainId: TOKENS_MOCK[1].chainId, }), ); + // Selecting a Perps token via this nested picker must mark the selection + // so PerpsPayRow doesn't misread the sheet close as a dismissal. + expect(markPerpsPaymentTokenSelection).toHaveBeenCalled(); }); it('calls onPredictPaymentTokenChange via close callback when type is predictDepositAndOrder', async () => { diff --git a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index d476209930d..58e05d8f386 100644 --- a/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/app/components/Views/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -35,6 +35,7 @@ import { HIDE_NETWORK_FILTER_TYPES } from '../../../constants/confirmations'; import { useMusdPaymentToken } from '../../../../../UI/Earn/hooks/useMusdPaymentToken'; import { usePerpsBalanceTokenFilter } from '../../../../../UI/Perps/hooks/usePerpsBalanceTokenFilter'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { markPerpsPaymentTokenSelection } from '../../../../../UI/Perps/utils/perpsPaymentTokenSelection'; import { usePredictBalanceTokenFilter } from '../../../../../UI/Predict/hooks/usePredictBalanceTokenFilter'; import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; import { usePayWithNoFeeToken } from '../../../hooks/pay/usePayWithNoFeeToken'; @@ -152,6 +153,10 @@ export function PayWithModal() { TransactionType.perpsDepositAndOrder, ]) ) { + // Selecting a token via this nested picker is an explicit Perps + // selection — mark it so PerpsPayRow doesn't misread the sheet close + // as a dismissal (even when the token identity is unchanged). + markPerpsPaymentTokenSelection(); onPerpsPaymentTokenChange(token); return; } diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts index e30aa8c8c60..eda63adc906 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithCryptoSection.ts @@ -25,6 +25,7 @@ import { } from '../../../components/modals/pay-with-bottom-sheet/pay-with-bottom-sheet.types'; import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; +import { markPerpsPaymentTokenSelection } from '../../../../../UI/Perps/utils/perpsPaymentTokenSelection'; import { usePredictPaymentToken } from '../../../../../UI/Predict/hooks/usePredictPaymentToken'; import { hasTransactionType, @@ -154,6 +155,9 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { chainId: preferredToken.chainId, }; if (isPerpsDepositAndOrder) { + // an explicit row press is a selection even when the pay token + // is unchanged (re-selecting the current preferred token). + markPerpsPaymentTokenSelection(); onPerpsPaymentTokenChange(target); } else if (isPredictDepositAndOrder) { onPredictPaymentTokenChange(target); @@ -183,6 +187,8 @@ export function usePayWithCryptoSection(): PayWithSectionConfig | null { chainId: noFeeToken.chainId, }; if (isPerpsDepositAndOrder) { + // explicit row press counts as a selection (see above). + markPerpsPaymentTokenSelection(); onPerpsPaymentTokenChange(target); } else if (isPredictDepositAndOrder) { onPredictPaymentTokenChange(target); diff --git a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx index ffe9e52d1f1..be01e69fa88 100644 --- a/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx +++ b/app/components/Views/confirmations/hooks/pay/sections/usePayWithPerpsSection.tsx @@ -19,6 +19,7 @@ import { selectPerpsAccountState } from '../../../../../UI/Perps/selectors/perps import { useIsPerpsBalanceSelected } from '../../../../../UI/Perps/hooks/useIsPerpsBalanceSelected'; import { usePerpsPaymentToken } from '../../../../../UI/Perps/hooks/usePerpsPaymentToken'; import { usePerpsTrading } from '../../../../../UI/Perps/hooks/usePerpsTrading'; +import { markPerpsPaymentTokenSelection } from '../../../../../UI/Perps/utils/perpsPaymentTokenSelection'; import useApprovalRequest from '../../useApprovalRequest'; import { useTransactionMetadataRequest } from '../../transactions/useTransactionMetadataRequest'; import { @@ -54,6 +55,9 @@ export function usePayWithPerpsSection(): PayWithSectionConfig | null { const clearPaymentOverride = useClearPaymentOverride(); const handleSelect = useCallback(() => { + // an explicit row press is a selection even when it does not + // change the pay token (e.g. re-selecting the already-selected balance). + markPerpsPaymentTokenSelection(); onPaymentTokenChange(null); clearPaymentOverride(); navigation.goBack(); diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index aa925e50108..79bfc16a302 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -633,6 +633,12 @@ enum EVENT_NAME { PERPS_RISK_MANAGEMENT = 'Perp Risk Management', PERPS_ERROR = 'Perp Error', PERPS_ACCOUNT_SETUP = 'Perp Account Setup', + // controller contract additions (PerpsAnalyticsEvent) + PERPS_TRANSACTION_CONSIDERED = 'Perp Transaction Considered', + PERPS_TRADE_QUOTE_RECEIVED = 'Perp Trade Quote Received', + PERPS_SEARCH_QUERY = 'Perp Search Query', + PERPS_SEARCH_RESULT_TAPPED = 'Perp Search Result Tapped', + PERPS_SEARCH_ABANDONED = 'Perp Search Abandoned', // Card CARD_BUTTON_VIEWED = 'Card Button Viewed', @@ -1798,6 +1804,17 @@ const events = { PERPS_RISK_MANAGEMENT: generateOpt(EVENT_NAME.PERPS_RISK_MANAGEMENT), PERPS_ERROR: generateOpt(EVENT_NAME.PERPS_ERROR), PERPS_ACCOUNT_SETUP: generateOpt(EVENT_NAME.PERPS_ACCOUNT_SETUP), + PERPS_TRANSACTION_CONSIDERED: generateOpt( + EVENT_NAME.PERPS_TRANSACTION_CONSIDERED, + ), + PERPS_TRADE_QUOTE_RECEIVED: generateOpt( + EVENT_NAME.PERPS_TRADE_QUOTE_RECEIVED, + ), + PERPS_SEARCH_QUERY: generateOpt(EVENT_NAME.PERPS_SEARCH_QUERY), + PERPS_SEARCH_RESULT_TAPPED: generateOpt( + EVENT_NAME.PERPS_SEARCH_RESULT_TAPPED, + ), + PERPS_SEARCH_ABANDONED: generateOpt(EVENT_NAME.PERPS_SEARCH_ABANDONED), // Asset Filter ASSET_FILTER_SELECTED: generateOpt(EVENT_NAME.ASSET_FILTER_SELECTED), diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePerpsUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePerpsUrl.test.ts index 93afb49c197..caec3567909 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePerpsUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePerpsUrl.test.ts @@ -17,6 +17,19 @@ jest.mock('../../../../redux', () => ({ }, })); jest.mock('../../../../../components/UI/Perps/selectors/perpsController'); +jest.mock('../../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + PerpsController: { + setAttributionContext: jest.fn(), + getAttributionContext: jest.fn(() => ({})), + clearAttributionContext: jest.fn(), + mergeAttributionContext: jest.fn((props = {}) => props), + }, + }, + }, +})); describe('handlePerpsUrl', () => { let mockNavigate: jest.Mock; diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts index 1c92230ae1c..5d33c56d1db 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts @@ -7,6 +7,10 @@ import { import DevLogger from '../../../SDKConnect/utils/DevLogger'; import ReduxService from '../../../redux'; import { selectIsFirstTimePerpsUser } from '../../../../components/UI/Perps/selectors/perpsController'; +import { + parsePerpsUtmFromPath, + setPerpsUtmAttribution, +} from '../../../../components/UI/Perps/utils/perpsAnalyticsAttribution'; import type { DeeplinkIntent } from '../../types/DeeplinkIntent'; import { executeDeeplinkIntent } from '../../utils/executeDeeplinkIntent'; @@ -265,6 +269,18 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { perpsPath, ); + // Propagate UTM params into controller attribution context. + try { + setPerpsUtmAttribution(parsePerpsUtmFromPath(perpsPath)); + // Attribution is best-effort: Engine/controller may be unavailable during + // early deeplink handling; never block navigation if UTM write fails. + } catch (attributionError) { + DevLogger.log( + '[handlePerpsUrl] Failed to set attribution context:', + attributionError, + ); + } + try { await executeDeeplinkIntent(createPerpsDeeplinkIntent({ perpsPath })); } catch (error) {