From ebd6af7a88406b7c6d2bc54275d9f8a9677b9c17 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 9 Jul 2026 14:00:47 +0100 Subject: [PATCH 01/25] feat(perps): consume controller analytics contract (TAT-3463) Wire Mobile to the perps-controller analytics contract: register new MetaMetrics events, propagate entry/discovery/UTM and fee fields into trackingData, emit controller search events, and remove duplicate client-side trade transaction emissions. Co-authored-by: Cursor --- .../PerpsClosePositionView.tsx | 5 + .../PerpsMarketDetailsView.tsx | 7 +- .../PerpsMarketListView.tsx | 40 ++++- .../PerpsOrderDetailsView.tsx | 8 + .../Views/PerpsOrderView/PerpsOrderView.tsx | 8 +- .../Views/PerpsTPSLView/PerpsTPSLView.tsx | 9 +- .../PerpsFlipPositionConfirmSheet.tsx | 18 +- .../PerpsMarketTabs/PerpsMarketTabs.tsx | 8 + .../hooks/usePerpsOrderExecution.test.ts | 165 ++---------------- .../UI/Perps/hooks/usePerpsOrderExecution.ts | 128 +------------- .../utils/perpsAnalyticsAttribution.test.ts | 93 ++++++++++ .../Perps/utils/perpsAnalyticsAttribution.ts | 86 +++++++++ app/core/Analytics/MetaMetrics.events.ts | 17 ++ .../legacy/__tests__/handlePerpsUrl.test.ts | 13 ++ .../handlers/legacy/handlePerpsUrl.ts | 14 ++ 15 files changed, 327 insertions(+), 292 deletions(-) create mode 100644 app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts create mode 100644 app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index bcee7dfab6e..e2582f68f10 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -65,6 +65,7 @@ import { formatPerpsFiat, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { calculateCloseAmountFromPercentage, validateCloseAmountLimits, @@ -398,6 +399,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.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 616ce0c21ee..ba5e060c140 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -135,6 +135,7 @@ import { selectSelectedInternalAccountAddress } from '../../../../../selectors/a import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests'; import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest'; import { getMarketHoursStatus } from '../../utils/marketHours'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { normalizeMarketDetailsOrders } from '../../normalization/normalizeMarketDetailsOrders'; import { ensureError } from '../../../../../util/errorUtils'; import { @@ -1067,10 +1068,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.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 52330bc9f64..deabbdcbcc0 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -129,6 +129,8 @@ const PerpsMarketListView = ({ // Destructure market type filter state const { marketTypeFilter, setMarketTypeFilter } = marketTypeFilterState; + const { track } = usePerpsEventTracking(); + // Handler for market press (defined early to avoid use-before-define) const handleMarketPress = useCallback( (market: PerpsMarketData) => { @@ -140,6 +142,16 @@ const PerpsMarketListView = ({ const trimmedQuery = searchQuery.trim(); if (trimmedQuery) { source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.ACTIVE_SEARCH; + const resultRank = + filteredMarkets.findIndex((m) => m.symbol === market.symbol) + 1; + track(MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery, + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, + ...(resultRank > 0 + ? { [PERPS_EVENT_PROPERTY.RESULT_RANK]: resultRank } + : {}), + [PERPS_EVENT_PROPERTY.ASSET]: market.symbol, + }); } else if (showFavoritesOnly) { source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.WATCHLIST; } else if (marketTypeFilter !== 'all') { @@ -175,11 +187,11 @@ const PerpsMarketListView = ({ searchQuery, showFavoritesOnly, marketTypeFilter, + filteredMarkets, + track, ], ); - const { track } = usePerpsEventTracking(); - // Handle category badge selection — clears watchlist filter (mutual exclusivity) const handleCategorySelect = useCallback( (category: MarketTypeFilter) => { @@ -225,24 +237,36 @@ const PerpsMarketListView = ({ const handleBackPressed = perpsNavigation.navigateBack; - // Debounced search result_count tracking — fires ~600ms after the query/result + // Debounced Search Query tracking — fires ~600ms after the query/result // count stabilises. Includes zero-result searches so analysts can measure failure. + // Uses controller contract event PERPS_SEARCH_QUERY (TAT-3463). const searchResultTimerRef = useRef | null>( null, ); + const lastEmittedSearchQueryRef = useRef(''); useEffect(() => { const trimmedQuery = searchQuery.trim(); - if (!trimmedQuery) return; + if (!trimmedQuery) { + if (lastEmittedSearchQueryRef.current) { + track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: + lastEmittedSearchQueryRef.current, + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, + }); + lastEmittedSearchQueryRef.current = ''; + } + return; + } if (searchResultTimerRef.current) { clearTimeout(searchResultTimerRef.current); } 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, + lastEmittedSearchQueryRef.current = trimmedQuery; + track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery, + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, }); }, 600); diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx index 4ee288b3028..9c27dc1980d 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, getValidOrderPrice, @@ -215,6 +217,12 @@ const PerpsOrderDetailsView: React.FC = () => { const result = await cancelOrder({ orderId: order.orderId, symbol: order.symbol, + trackingData: { + source: PERPS_EVENT_VALUE.SOURCE.TRADE_SCREEN, + ...toPerpsEntryAttribution({ + source: PERPS_EVENT_VALUE.SOURCE.TRADE_SCREEN, + }), + }, }); // Show success/failure toast diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6195bd5df21..fc6c2824dc5 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -139,6 +139,7 @@ import { PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { willFlipPosition } from '../../utils/orderUtils'; +import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { calculateRoEForPrice, isStopLossSafeFromLiquidation, @@ -1175,7 +1176,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, @@ -1186,6 +1187,10 @@ const PerpsOrderViewContentBase: React.FC = ({ estimatedPoints: feeResults.estimatedPoints, inputMethod: inputMethodRef.current, source, + ...toPerpsEntryAttribution({ source }), + ...(feeResults.protocolFeeRate !== undefined + ? { hlFeeRate: feeResults.protocolFeeRate } + : {}), tradeAction: currentMarketPosition ? 'increase_exposure' : 'create_position', @@ -1280,6 +1285,7 @@ const PerpsOrderViewContentBase: React.FC = ({ feeResults.totalFee, feeResults.metamaskFee, feeResults.metamaskFeeRate, + feeResults.protocolFeeRate, feeResults.feeDiscountPercentage, feeResults.estimatedPoints, source, diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.tsx index 74257c48519..e5a5f5ab301 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'; const PerpsTPSLView: React.FC = () => { @@ -394,11 +395,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, takeProfitPercentage: formattedTakeProfitPercentage ? parseFloat(formattedTakeProfitPercentage.replace('%', '')) 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.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index 7338a741916..7a884d3ca3b 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,12 @@ const PerpsMarketTabs: React.FC = ({ const result = await controller.cancelOrder({ orderId: orderToCancel.orderId, symbol: orderToCancel.symbol, + trackingData: { + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ...toPerpsEntryAttribution({ + source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + }), + }, }); // Order cancellation successful diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts index 5ffc8b5d303..933342d068e 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts @@ -1,19 +1,9 @@ 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'; jest.mock('./usePerpsTrading'); -const mockTrack = jest.fn(); -jest.mock('./usePerpsEventTracking', () => ({ - usePerpsEventTracking: () => ({ track: mockTrack }), -})); jest.mock('./usePerpsMeasurement', () => ({ usePerpsMeasurement: jest.fn(), })); @@ -40,7 +30,6 @@ describe('usePerpsOrderExecution', () => { beforeEach(() => { jest.clearAllMocks(); - mockTrack.mockClear(); mockGetPositions.mockResolvedValue([]); // Setup default (usePerpsTrading as jest.Mock).mockReturnValue({ placeOrder: mockPlaceOrder, @@ -164,7 +153,7 @@ describe('usePerpsOrderExecution', () => { expect(onError).not.toHaveBeenCalled(); }); - it('tracks partially filled event with trackingData when filledSize is between 0 and order size', async () => { + it('still calls onSuccess when filledSize is partial (controller owns trade analytics)', async () => { const onSuccess = jest.fn(); const paramsWithTracking: OrderParams = { ...mockOrderParams, @@ -197,57 +186,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_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, - }, - }; - - mockPlaceOrder.mockResolvedValue({ - success: true, - orderId: 'order123', - filledSize: '0.1', - }); - mockGetPositions.mockResolvedValue([mockPosition]); - - 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); }); }); @@ -282,7 +222,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, @@ -310,52 +250,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_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 () => { @@ -398,7 +294,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, @@ -423,49 +319,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_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 7239d7af3d3..67678c304d6 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -2,18 +2,14 @@ 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_PROPERTY, - 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'; @@ -33,14 +29,17 @@ interface UsePerpsOrderExecutionReturn { /** * Hook to handle order execution flow - * Manages loading states, success/error handling, and position fetching + * Manages loading states, success/error handling, and position fetching. + * + * Trade transaction analytics (submitted + terminal) are emitted by + * `@metamask/perps-controller` TradingService — do not re-emit + * PERPS_TRADE_TRANSACTION from this hook. */ export function usePerpsOrderExecution( params: UsePerpsOrderExecutionParams = {}, ): UsePerpsOrderExecutionReturn { const { onSubmitted, onSuccess, onError } = params; const { placeOrder: controllerPlaceOrder, getPositions } = usePerpsTrading(); - const { track } = usePerpsEventTracking(); const [isPlacing, setIsPlacing] = useState(false); const [lastResult, setLastResult] = useState(); @@ -78,50 +77,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 (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); - } - // Try to fetch the newly created position try { // Add a small delay to ensure the position is available @@ -159,38 +114,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 (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) { @@ -227,51 +150,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 (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, - getPositions, - onSubmitted, - onSuccess, - onError, - track, - ], + [controllerPlaceOrder, getPositions, onSubmitted, onSuccess, onError], ); return { 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..6dc3bd9a790 --- /dev/null +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts @@ -0,0 +1,93 @@ +import { + hasPerpsUtmAttribution, + parsePerpsUtmFromPath, + setPerpsUtmAttribution, + toPerpsEntryAttribution, +} from './perpsAnalyticsAttribution'; + +const mockSetAttributionContext = jest.fn(); + +jest.mock('../../../../core/Engine', () => ({ + __esModule: true, + default: { + context: { + PerpsController: { + setAttributionContext: (...args: unknown[]) => + mockSetAttributionContext(...args), + }, + }, + }, +})); + +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(); + }); + }); +}); diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts new file mode 100644 index 00000000000..d8896791da5 --- /dev/null +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts @@ -0,0 +1,86 @@ +/** + * Helpers for mapping Mobile navigation / deeplink context onto the + * perps-controller analytics attribution contract (TAT-3463). + */ + +import type { + PerpsAttributionContext, + TrackingData, +} from '@metamask/perps-controller'; +import Engine from '../../../../core/Engine'; + +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. + */ +export function setPerpsUtmAttribution(context: PerpsAttributionContext): void { + if (!hasPerpsUtmAttribution(context)) { + return; + } + Engine.context.PerpsController.setAttributionContext(context); +} diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 67e67662cde..9a2882efbfc 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -631,6 +631,12 @@ enum EVENT_NAME { PERPS_RISK_MANAGEMENT = 'Perp Risk Management', PERPS_ERROR = 'Perp Error', PERPS_ACCOUNT_SETUP = 'Perp Account Setup', + // TAT-3463 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', @@ -1783,6 +1789,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..dff0c3b7ea6 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,16 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { perpsPath, ); + // Propagate UTM params into controller attribution context (TAT-3463). + try { + setPerpsUtmAttribution(parsePerpsUtmFromPath(perpsPath)); + } catch (attributionError) { + DevLogger.log( + '[handlePerpsUrl] Failed to set attribution context:', + attributionError, + ); + } + try { await executeDeeplinkIntent(createPerpsDeeplinkIntent({ perpsPath })); } catch (error) { From 0012e3027d160192058c5713d1acec390ea6bd36 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 9 Jul 2026 14:25:29 +0100 Subject: [PATCH 02/25] fix: address self-review feedback (MANUAL-000002) Document why deeplink UTM attribution failures are swallowed so navigation continues when Engine/controller is unavailable. Co-authored-by: Cursor --- app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts index dff0c3b7ea6..a45881b1ae5 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts @@ -272,6 +272,8 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { // Propagate UTM params into controller attribution context (TAT-3463). 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:', From 57ef68638e9e0db66d2f20206c5e36ac224c1e19 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Thu, 9 Jul 2026 17:55:10 +0100 Subject: [PATCH 03/25] fix: forward discovery source_section into order tracking (MANUAL-000002) Pass market-list source_section through market details into order route params and toPerpsEntryAttribution so trade trackingData includes discoverySource fields. Co-authored-by: Cursor --- .../PerpsMarketDetailsView.test.tsx | 45 +++++++++++++++++++ .../PerpsMarketDetailsView.tsx | 2 + .../PerpsOrderView/PerpsOrderView.test.tsx | 38 ++++++++++++++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 6 ++- app/components/UI/Perps/types/navigation.ts | 2 + 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index c152416c5ca..fba36f83338 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -217,6 +217,7 @@ const mockRouteParams: { asset: string; monitor: 'orders' | 'positions' | 'both'; }; + source_section?: string; transactionActiveAbTests?: { key: string; value: string; @@ -899,6 +900,7 @@ describe('PerpsMarketDetailsView', () => { maxLeverage: '40x', }; mockRouteParams.transactionActiveAbTests = undefined; + mockRouteParams.source_section = undefined; // Reset order fills mock to default mockUsePerpsLiveFillsImpl.mockReturnValue({ @@ -2096,6 +2098,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 ba5e060c140..ec54ceb6b20 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -805,6 +805,7 @@ const PerpsMarketDetailsView: React.FC = () => { direction, asset: market.symbol, source: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, + ...(source_section ? { source_section } : {}), defaultSzDecimals: marketData?.szDecimals, defaultMaxLeverage: marketData?.maxLeverage, ...(transactionActiveAbTests?.length @@ -819,6 +820,7 @@ const PerpsMarketDetailsView: React.FC = () => { navigation, track, navigateToOrder, + source_section, transactionActiveAbTests, market?.symbol, marketData, diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 23eecb66f3d..c1a7df84d30 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -1216,6 +1216,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); diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index fc6c2824dc5..4e97b87fe88 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -177,6 +177,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; defaultSzDecimals?: number; defaultMaxLeverage?: number; } @@ -211,6 +213,7 @@ const PerpsOrderViewContentBase: React.FC = ({ (TrendingFeedSessionManager.getInstance().isFromTrending ? 'trending' : undefined); + const sourceSection = route.params?.source_section; const fromTokenDetails = route.params?.fromTokenDetails ?? false; const { colors } = useTheme(); const insets = useSafeAreaInsets(); @@ -1187,7 +1190,7 @@ const PerpsOrderViewContentBase: React.FC = ({ estimatedPoints: feeResults.estimatedPoints, inputMethod: inputMethodRef.current, source, - ...toPerpsEntryAttribution({ source }), + ...toPerpsEntryAttribution({ source, sourceSection }), ...(feeResults.protocolFeeRate !== undefined ? { hlFeeRate: feeResults.protocolFeeRate } : {}), @@ -1289,6 +1292,7 @@ const PerpsOrderViewContentBase: React.FC = ({ feeResults.feeDiscountPercentage, feeResults.estimatedPoints, source, + sourceSection, isButtonColorTestEnabled, buttonColorVariant, isTradeWithAnyTokenEnabled, diff --git a/app/components/UI/Perps/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index 1f76518ff10..ec2465ac29c 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -47,6 +47,8 @@ export type PerpsNavigationParamList = { 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; transactionActiveAbTests?: TransactionActiveAbTestEntry[]; }; From 3d3171df0b84f2721c255e5dea9a8bd578a7f37a Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 07:44:23 +0100 Subject: [PATCH 04/25] fix: supply required TrackingData fields on cancel analytics Cancel flows now include totalFee and marketPrice so TrackingData matches the controller contract after merging main. Co-authored-by: Cursor --- .../PerpsOrderDetailsView/PerpsOrderDetailsView.tsx | 13 ++++++++++++- .../components/PerpsMarketTabs/PerpsMarketTabs.tsx | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx index 9c27dc1980d..7668f69c9f7 100644 --- a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.tsx @@ -218,6 +218,8 @@ const PerpsOrderDetailsView: React.FC = () => { 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, @@ -245,7 +247,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/components/PerpsMarketTabs/PerpsMarketTabs.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx index 7a884d3ca3b..538b4dbba7c 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.tsx @@ -556,6 +556,8 @@ const PerpsMarketTabs: React.FC = ({ 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, @@ -630,6 +632,7 @@ const PerpsMarketTabs: React.FC = ({ }, [ position?.size, + currentPrice, showToast, PerpsToastOptions.orderManagement.shared, onOrderCancelled, From b6b6ee01f966fa5350845647c4aa46c967c43622 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 16:33:48 +0800 Subject: [PATCH 05/25] fix(perps): track abandoned search result count --- .../Views/PerpsMarketListView/PerpsMarketListView.test.tsx | 6 +++--- .../Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 8b1cdfce9d2..1b17da37af2 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -1877,10 +1877,10 @@ describe('PerpsMarketListView', () => { await waitFor(() => { expect(mockTrack).toHaveBeenCalledWith( - expect.anything(), + MetaMetricsEvents.PERPS_SEARCH_QUERY, expect.objectContaining({ - [PEP.INTERACTION_TYPE]: PEV.INTERACTION_TYPE.SEARCH_CLICKED, - [PEP.RESULT_COUNT]: 1, + [PEP.SEARCH_QUERY]: 'bitcoin', + [PEP.RESULTS_COUNT]: 1, }), ); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index deabbdcbcc0..d3769fab114 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -244,6 +244,7 @@ const PerpsMarketListView = ({ null, ); const lastEmittedSearchQueryRef = useRef(''); + const lastEmittedSearchResultsCountRef = useRef(0); useEffect(() => { const trimmedQuery = searchQuery.trim(); if (!trimmedQuery) { @@ -251,9 +252,11 @@ const PerpsMarketListView = ({ track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: lastEmittedSearchQueryRef.current, - [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: + lastEmittedSearchResultsCountRef.current, }); lastEmittedSearchQueryRef.current = ''; + lastEmittedSearchResultsCountRef.current = 0; } return; } @@ -264,6 +267,7 @@ const PerpsMarketListView = ({ searchResultTimerRef.current = setTimeout(() => { lastEmittedSearchQueryRef.current = trimmedQuery; + lastEmittedSearchResultsCountRef.current = filteredMarkets.length; track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery, [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, From aed0571ada41f45f08e55c8099359a34a4c3ed51 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 17:29:26 +0800 Subject: [PATCH 06/25] test(perps): align analytics contract expectations --- .../PerpsMarketListView/PerpsMarketListView.test.tsx | 1 + .../PerpsOrderDetailsView.test.tsx | 12 ++++++------ .../PerpsMarketTabs/PerpsMarketTabs.test.tsx | 12 ++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 1b17da37af2..e233ecfa053 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -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'), diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx index be25d898f89..c422907e4f2 100644 --- a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx @@ -210,10 +210,10 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: 'order-123', symbol: 'xyz:MSTR', - }); + })); }); }); @@ -288,10 +288,10 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: 'child-tp-order-123', symbol: 'BTC', - }); + })); }); }); @@ -501,10 +501,10 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: 'order-123', symbol: 'BTC', - }); + })); }); await waitFor(() => { diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index 0b17436e2c7..aa8fbe9f35a 100644 --- a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx +++ b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx @@ -920,10 +920,10 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: btcOrder.orderId, symbol: btcOrder.symbol, - }); + })); }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -973,10 +973,10 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: btcOrder.orderId, symbol: btcOrder.symbol, - }); + })); }); expect(mockShowToast).toHaveBeenCalledTimes(2); @@ -1020,10 +1020,10 @@ describe('PerpsMarketTabs', () => { fireEvent.press(orderCard); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith({ + expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: btcOrder.orderId, symbol: btcOrder.symbol, - }); + })); }); expect(mockShowToast).toHaveBeenCalledTimes(2); From 1522d06d0fe7a3e16559ce12e98fb0738977f2f1 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 18:30:35 +0800 Subject: [PATCH 07/25] test(perps): allow close tracking metadata --- .../PerpsClosePositionView.test.tsx | 100 +++++++++--------- .../PerpsOrderDetailsView.test.tsx | 30 +++--- .../PerpsMarketTabs/PerpsMarketTabs.test.tsx | 30 +++--- 3 files changed, 88 insertions(+), 72 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index eb53042d266..e763292088e 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -2082,31 +2082,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: 300, // maxSlippageBps: 3% slippage tolerance (300 basis points) - conservative default - }, - }); + expect(handleClosePosition).toHaveBeenCalledWith( + expect.objectContaining({ + 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: 300, // maxSlippageBps: 3% slippage tolerance (300 basis points) - conservative default + }, + }), + ); }); it('validates limit order requires price before confirmation', async () => { @@ -2209,29 +2211,31 @@ describe('PerpsClosePositionView', () => { // Assert - Should call with limit price and specific calculated values await waitFor(() => { - expect(handleClosePosition).toHaveBeenCalledWith({ - position: defaultPerpsPositionMock, - size: '', - orderType: 'limit', - limitPrice: '50000', - trackingData: { - totalFee: 45, - marketPrice: 3000, - receivedAmount: 1405, - realizedPnl: 150, - metamaskFeeRate: 0, - metamaskFee: 0, - feeDiscountPercentage: undefined, - estimatedPoints: undefined, - inputMethod: 'default', - }, - marketPrice: '3000.00', - slippage: { - usdAmount: '4500', - priceAtCalculation: 3000, - maxSlippageBps: 100, - }, - }); + expect(handleClosePosition).toHaveBeenCalledWith( + expect.objectContaining({ + position: defaultPerpsPositionMock, + size: '', + orderType: 'limit', + limitPrice: '50000', + trackingData: { + totalFee: 45, + marketPrice: 3000, + receivedAmount: 1405, + realizedPnl: 150, + metamaskFeeRate: 0, + metamaskFee: 0, + feeDiscountPercentage: undefined, + estimatedPoints: undefined, + inputMethod: 'default', + }, + marketPrice: '3000.00', + slippage: { + usdAmount: '4500', + priceAtCalculation: 3000, + maxSlippageBps: 100, + }, + }), + ); }); }); }); diff --git a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx index c422907e4f2..e03b3c4ce14 100644 --- a/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderDetailsView/PerpsOrderDetailsView.test.tsx @@ -210,10 +210,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ - orderId: 'order-123', - symbol: 'xyz:MSTR', - })); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'order-123', + symbol: 'xyz:MSTR', + }), + ); }); }); @@ -288,10 +290,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ - orderId: 'child-tp-order-123', - symbol: 'BTC', - })); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'child-tp-order-123', + symbol: 'BTC', + }), + ); }); }); @@ -501,10 +505,12 @@ describe('PerpsOrderDetailsView', () => { fireEvent.press(screen.getByText('perps.order_details.cancel_order')); await waitFor(() => { - expect(mockCancelOrder).toHaveBeenCalledWith(expect.objectContaining({ - orderId: 'order-123', - symbol: 'BTC', - })); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: 'order-123', + symbol: 'BTC', + }), + ); }); await waitFor(() => { diff --git a/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx b/app/components/UI/Perps/components/PerpsMarketTabs/PerpsMarketTabs.test.tsx index aa8fbe9f35a..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(expect.objectContaining({ - 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(expect.objectContaining({ - 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(expect.objectContaining({ - orderId: btcOrder.orderId, - symbol: btcOrder.symbol, - })); + expect(mockCancelOrder).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: btcOrder.orderId, + symbol: btcOrder.symbol, + }), + ); }); expect(mockShowToast).toHaveBeenCalledTimes(2); From 9db810fd46c3fe8149594b74c77e835f1827b875 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 19:29:24 +0800 Subject: [PATCH 08/25] test(perps): update search and flip analytics mocks --- .../Views/PerpsMarketListView/PerpsMarketListView.test.tsx | 2 +- .../PerpsFlipPositionConfirmSheet.test.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index e233ecfa053..e4486817dea 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -1880,7 +1880,7 @@ describe('PerpsMarketListView', () => { expect(mockTrack).toHaveBeenCalledWith( MetaMetricsEvents.PERPS_SEARCH_QUERY, expect.objectContaining({ - [PEP.SEARCH_QUERY]: 'bitcoin', + [PEP.SEARCH_QUERY]: 'BT', [PEP.RESULTS_COUNT]: 1, }), ); 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', () => { From 152fb4bd6577bf9b7d5032c4f703a31eff9921a7 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 13:19:07 +0100 Subject: [PATCH 09/25] test(perps): allow close tracking metadata --- .../PerpsClosePositionView.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index e763292088e..6980835dd11 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -85,11 +85,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' }), }; }); @@ -2088,7 +2088,7 @@ describe('PerpsClosePositionView', () => { size: '', orderType: 'market', limitPrice: undefined, - trackingData: { + trackingData: expect.objectContaining({ totalFee: 45, marketPrice: 3000, receivedAmount: 1405, @@ -2098,7 +2098,7 @@ describe('PerpsClosePositionView', () => { 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 @@ -2217,7 +2217,7 @@ describe('PerpsClosePositionView', () => { size: '', orderType: 'limit', limitPrice: '50000', - trackingData: { + trackingData: expect.objectContaining({ totalFee: 45, marketPrice: 3000, receivedAmount: 1405, @@ -2227,7 +2227,7 @@ describe('PerpsClosePositionView', () => { feeDiscountPercentage: undefined, estimatedPoints: undefined, inputMethod: 'default', - }, + }), marketPrice: '3000.00', slippage: { usdAmount: '4500', From 476488faf923cb212e10dab272af330b5de26715 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 13:37:09 +0100 Subject: [PATCH 10/25] test(perps): allow tpsl tracking metadata --- .../UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx index d4ec56b1201..e5cbc714994 100644 --- a/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx @@ -575,7 +575,7 @@ describe('PerpsTPSLView', () => { undefined, '3150.00', '2850.00', - { + expect.objectContaining({ direction: 'long', source: PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN, positionSize: 0, @@ -583,7 +583,7 @@ describe('PerpsTPSLView', () => { stopLossPercentage: undefined, isEditingExistingPosition: false, entryPrice: 3000, - }, + }), ); }); @@ -606,7 +606,7 @@ describe('PerpsTPSLView', () => { undefined, undefined, undefined, - { + expect.objectContaining({ direction: 'long', source: PERPS_EVENT_VALUE.RISK_MANAGEMENT_SOURCE.TRADE_SCREEN, positionSize: 0, @@ -614,7 +614,7 @@ describe('PerpsTPSLView', () => { stopLossPercentage: undefined, isEditingExistingPosition: false, entryPrice: 3000, - }, + }), ); }); From 5f29a134d8027bcd6fac4883b02dfb601d772e09 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 17:53:07 +0100 Subject: [PATCH 11/25] feat(perps): complete analytics ticket coverage and discovery funnel Add UTM attribution on screen views, place-order and abandon-order interactions, transaction-considered and quote-received events, the market search/discovery funnel (query, result-tapped, abandoned, sort and filter interactions, results/no-results screen views), add/remove margin and error screen views, watchlisted flag on asset detail, and the payment-token selector dismissed event. Route all Perps screen views through the shared tracking hook so attribution merges consistently. --- .../PerpsAdjustMarginView.test.tsx | 5 + .../PerpsAdjustMarginView.tsx | 20 +- .../PerpsClosePositionView.test.tsx | 96 ++++- .../PerpsClosePositionView.tsx | 36 ++ .../PerpsMarketDetailsView.tsx | 3 +- .../PerpsMarketListView.test.tsx | 377 +++++++++++++++++- .../PerpsMarketListView.tsx | 238 +++++++++-- .../PerpsOrderView/PerpsOrderView.test.tsx | 89 +++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 213 +++++++++- .../Views/PerpsOrderView/PerpsPayRow.test.tsx | 108 +++++ .../Views/PerpsOrderView/PerpsPayRow.tsx | 45 ++- .../PerpsTPSLView/PerpsTPSLView.test.tsx | 5 + .../PerpsWithdrawView.test.tsx | 5 + .../PerpsOpenOrderCard.test.tsx | 5 + .../PerpsOpenOrderCard/PerpsOpenOrderCard.tsx | 31 +- .../usePerpsAbandonOrderTracking.test.ts | 115 ++++++ .../hooks/usePerpsAbandonOrderTracking.ts | 97 +++++ .../Perps/hooks/usePerpsEventTracking.test.ts | 54 +++ .../UI/Perps/hooks/usePerpsEventTracking.ts | 21 +- .../UI/Perps/selectors/featureFlags/index.ts | 4 +- .../UI/Perps/utils/abTesting/tests.ts | 2 +- .../Perps/utils/perpsAnalyticsAttribution.ts | 12 +- .../Perps/utils/perpsPaymentTokenSelection.ts | 29 ++ .../pay-with-modal/pay-with-modal.test.tsx | 5 + .../modals/pay-with-modal/pay-with-modal.tsx | 5 + .../pay/sections/usePayWithCryptoSection.ts | 6 + .../pay/sections/usePayWithPerpsSection.tsx | 4 + app/core/Analytics/MetaMetrics.events.ts | 2 +- .../handlers/legacy/handlePerpsUrl.ts | 2 +- docs/perps/perps-ab-testing.md | 24 +- docs/perps/perps-feature-flags.md | 6 +- docs/perps/perps-metametrics-reference.md | 8 +- 32 files changed, 1565 insertions(+), 107 deletions(-) create mode 100644 app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts create mode 100644 app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts create mode 100644 app/components/UI/Perps/utils/perpsPaymentTokenSelection.ts 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 6980835dd11..5900af9f4ee 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -1,4 +1,9 @@ -import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { + PERPS_EVENT_PROPERTY, + PERPS_EVENT_VALUE, +} from '@metamask/perps-controller'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { @@ -170,6 +175,7 @@ describe('PerpsClosePositionView', () => { // Setup navigation mocks useNavigationMock.mockReturnValue({ goBack: mockGoBack, + addListener: jest.fn(() => jest.fn()), }); // Setup default route params @@ -3268,4 +3274,92 @@ 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, + ); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index e2582f68f10..5abf028a714 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -59,6 +59,7 @@ import { usePerpsTopOfBook, } from '../../hooks/stream'; import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking'; +import { usePerpsAbandonOrderTracking } from '../../hooks/usePerpsAbandonOrderTracking'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { formatPositionSize, @@ -94,6 +95,8 @@ const PerpsClosePositionView: React.FC = () => { const inputMethodRef = useRef('default'); const isAmountInitializedRef = useRef(false); + const hasConfirmedCloseRef = useRef(false); + const latestAbandonPropsRef = useRef>({}); const { showToast, PerpsToastOptions } = usePerpsToasts(); @@ -343,9 +346,39 @@ const PerpsClosePositionView: React.FC = () => { [PERPS_EVENT_PROPERTY.UNREALIZED_PNL_PERCENT]: unrealizedPnlPercent, [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: receiveAmount, + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: isPartialClose + ? PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE + : PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + [PERPS_EVENT_PROPERTY.BUTTON_LOCATION]: + 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) { @@ -380,6 +413,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(); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 43b8a55db31..85a8f161da4 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -650,11 +650,12 @@ 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]: isServiceInterruptionBannerEnabled, - // A/B Test context (TAT-1937) - for baseline exposure tracking + // A/B Test context - for baseline exposure tracking ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, }), diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index e4486817dea..7eb2212bc72 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, @@ -1813,6 +1813,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 }); @@ -1873,21 +1890,373 @@ 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( MetaMetricsEvents.PERPS_SEARCH_QUERY, expect.objectContaining({ - [PEP.SEARCH_QUERY]: 'BT', + [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(), + }, + 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(), + }, + 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('does NOT flush a pending search query on blur while results are still loading', () => { + 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(), + }, + marketCounts: { + crypto: 0, + stocks: 0, + 'pre-ipo': 0, + indices: 0, + etfs: 0, + commodities: 0, + forex: 0, + new: 0, + }, + // Results still loading — a mid-load flush would 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 dropped (not emitted as settled) while loading. + expect(mockTrack).not.toHaveBeenCalledWith( + MetaMetricsEvents.PERPS_SEARCH_QUERY, + expect.anything(), + ); + + jest.useRealTimers(); + }); + + 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(), + }, + 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(), + }, + 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('Edge Cases', () => { diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index d3769fab114..a30c28f50bb 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -47,6 +47,7 @@ import { useRoute, RouteProp, useNavigation, + useFocusEffect, StackActions, } from '@react-navigation/native'; import Routes from '../../../../../constants/navigation/Routes'; @@ -131,6 +132,22 @@ const PerpsMarketListView = ({ 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(''); + const lastEmittedSearchResultsCountRef = useRef(0); + 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); + // 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); + // Handler for market press (defined early to avoid use-before-define) const handleMarketPress = useCallback( (market: PerpsMarketData) => { @@ -144,14 +161,22 @@ const PerpsMarketListView = ({ source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.ACTIVE_SEARCH; const resultRank = filteredMarkets.findIndex((m) => m.symbol === market.symbol) + 1; + const timeToTapMs = + searchStartTimeRef.current !== null + ? Date.now() - searchStartTimeRef.current + : undefined; track(MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, { - [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery, + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery.toLowerCase(), [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.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') { @@ -202,6 +227,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); }, @@ -219,12 +249,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, { @@ -237,49 +292,176 @@ const PerpsMarketListView = ({ const handleBackPressed = perpsNavigation.navigateBack; - // Debounced Search Query tracking — fires ~600ms after the query/result - // count stabilises. Includes zero-result searches so analysts can measure failure. - // Uses controller contract event PERPS_SEARCH_QUERY (TAT-3463). - const searchResultTimerRef = useRef | null>( - null, - ); - const lastEmittedSearchQueryRef = useRef(''); - const lastEmittedSearchResultsCountRef = useRef(0); + // 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) => void>(() => { + // Assigned on every render below. + }); + emitSearchQueryRef.current = (trimmedQuery: string) => { + const normalizedQuery = trimmedQuery.toLowerCase(); + const resultCount = filteredMarkets.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 = resultCount; + searchQueryCountRef.current += 1; + + track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: normalizedQuery, + query_text: normalizedQuery, + query_length: normalizedQuery.length, + [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, + }); + + 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; + lastEmittedSearchQueryRef.current = ''; + lastEmittedSearchResultsCountRef.current = 0; + 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. A query whose + // results are still loading is dropped (not emitted as settled), matching the + // debounce gate, so result_count/has_results are never captured mid-load. + const flushPendingSearchQuery = useCallback(() => { + if (searchResultTimerRef.current) { + clearTimeout(searchResultTimerRef.current); + searchResultTimerRef.current = null; + } + if (pendingSearchQueryRef.current) { + if (!isLoadingMarketsRef.current) { + emitSearchQueryRef.current(pendingSearchQueryRef.current); + } + pendingSearchQueryRef.current = null; + } + }, []); + + const emitSearchAbandoned = useCallback(() => { + if (!lastEmittedSearchQueryRef.current) { + return; + } + track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { + [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: lastEmittedSearchQueryRef.current, + [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 = 0; + }, [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) { - if (lastEmittedSearchQueryRef.current) { - track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { - [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: - lastEmittedSearchQueryRef.current, - [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: - lastEmittedSearchResultsCountRef.current, - }); - lastEmittedSearchQueryRef.current = ''; - lastEmittedSearchResultsCountRef.current = 0; - } + emitSearchAbandoned(); + resetSearchSession(); return; } + if (searchStartTimeRef.current === null) { + searchStartTimeRef.current = Date.now(); + } + if (searchResultTimerRef.current) { clearTimeout(searchResultTimerRef.current); + searchResultTimerRef.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; } searchResultTimerRef.current = setTimeout(() => { - lastEmittedSearchQueryRef.current = trimmedQuery; - lastEmittedSearchResultsCountRef.current = filteredMarkets.length; - track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { - [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery, - [PERPS_EVENT_PROPERTY.RESULTS_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, + filteredMarkets.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({ @@ -551,7 +733,7 @@ const PerpsMarketListView = ({ onClose={() => setIsSortFieldSheetVisible(false)} selectedOptionId={selectedOptionId} sortDirection={direction} - onOptionSelect={handleOptionChange} + onOptionSelect={handleSortChange} testID={`${PerpsMarketListViewSelectorsIDs.SORT_FILTERS}-field-sheet`} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 8937b59753c..dae44b7c6d5 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', () => { @@ -161,6 +166,11 @@ jest.mock('../../hooks/stream', () => ({ })), })); +jest.mock('../../utils/perpsAnalyticsAttribution', () => ({ + ...jest.requireActual('../../utils/perpsAnalyticsAttribution'), + getPerpsUtmAttributionProperties: jest.fn(() => ({})), +})); + jest.mock('../../hooks/usePerpsNetworkManagement', () => ({ usePerpsNetworkManagement: () => ({ ensureArbitrumNetworkExists: jest.fn().mockResolvedValue(undefined), @@ -928,6 +938,7 @@ describe('PerpsOrderView', () => { (useNavigation as jest.Mock).mockReturnValue({ navigate: mockNavigate, goBack: mockGoBack, + addListener: jest.fn(() => jest.fn()), }); (useRoute as jest.Mock).mockReturnValue(defaultMockRoute); @@ -4427,4 +4438,82 @@ describe('PerpsOrderView', () => { expect(mockPlaceOrder).not.toHaveBeenCalled(); }); }); + + 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 43a59499e4f..3e350410a00 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -121,6 +121,7 @@ import { usePerpsEstimatedSlippage } from '../../hooks/usePerpsEstimatedSlippage import { usePerpsMaxSlippage } from '../../hooks/usePerpsMaxSlippage'; import { useIsPerpsBalanceSelected } from '../../hooks/useIsPerpsBalanceSelected'; 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'; @@ -283,9 +284,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(); @@ -413,6 +419,9 @@ const PerpsOrderViewContentBase: React.FC = ({ [PERPS_EVENT_PROPERTY.SOURCE]: source ?? PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [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, ...(isButtonColorTestEnabled && { @@ -740,6 +749,140 @@ const PerpsOrderViewContentBase: React.FC = ({ }, }); + // 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]: PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + [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, + hasCustomTokenSelected, + payToken, + track, + ]); + + // Emit "trade quote received" when a pay-with-token relay quote + // request completes (loading transitions true -> false). Only meaningful for + // the trade-with-token flow; the start time is captured when loading begins. + const payQuoteStartRef = useRef(null); + useEffect(() => { + if (!hasCustomTokenSelected) { + payQuoteStartRef.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; + } + + // isPayTotalsLoading === false: emit once when a tracked quote completes. + if (payQuoteStartRef.current !== null) { + const latencyMs = Date.now() - payQuoteStartRef.current; + payQuoteStartRef.current = null; + + const blockingNoQuoteAlert = noQuotesAlerts.find((a) => a.isBlocking); + const succeeded = Boolean(payTotals) && !blockingNoQuoteAlert; + + 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, + 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({ @@ -1004,6 +1147,34 @@ 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 + // A/B button color context is only attached for enrolled users. + if (!forceTrade) { + const placeOrderInteractionProps: Record = { + [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, + }; + if (isButtonColorTestEnabled) { + placeOrderInteractionProps[ + PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR + ] = buttonColorVariant; + } + track( + MetaMetricsEvents.PERPS_UI_INTERACTION, + placeOrderInteractionProps, + ); + } + // 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') { @@ -1051,7 +1222,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({ @@ -1080,21 +1267,10 @@ 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, - [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, - }); - } + // 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. try { // Validation errors are shown in the UI @@ -1150,6 +1326,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: { 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/PerpsTPSLView/PerpsTPSLView.test.tsx b/app/components/UI/Perps/Views/PerpsTPSLView/PerpsTPSLView.test.tsx index e5cbc714994..223e9ce68e1 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', 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/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..cb174615942 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts @@ -0,0 +1,115 @@ +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', + }); + return renderHook(() => + usePerpsAbandonOrderTracking({ getAbandonProperties, 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); + }); + + 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)', () => { + renderTracking(true); + fire(nav, 'beforeRemove'); + fire(nav, 'blur'); + + expect(mockTrack).not.toHaveBeenCalled(); + }); + + 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..5fbd8952d59 --- /dev/null +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts @@ -0,0 +1,97 @@ +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; + }, [navigation]), + ); + + 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/selectors/featureFlags/index.ts b/app/components/UI/Perps/selectors/featureFlags/index.ts index d4cb0ecc36c..b108afcdd4c 100644 --- a/app/components/UI/Perps/selectors/featureFlags/index.ts +++ b/app/components/UI/Perps/selectors/featureFlags/index.ts @@ -11,7 +11,7 @@ import { hasProperty } from '@metamask/utils'; import { parseAllowlistAssets } from '../../utils/parseAllowlistAssets'; /** - * Valid variants for button color A/B test (TAT-1937) + * Valid variants for button color A/B test * Used for runtime validation of LaunchDarkly responses */ const VALID_BUTTON_COLOR_VARIANTS: readonly ButtonColorVariantName[] = [ @@ -144,7 +144,7 @@ export const selectPerpsRelatedMarketsEnabledFlag = createSelector( /** * Selector for button color A/B test variant from LaunchDarkly - * TAT-1937: Tests impact of button colors (green/red vs white/white) on trading behavior + * Tests impact of button colors (green/red vs white/white) on trading behavior * * @returns Variant name ('control' | 'monochrome') or null if test is disabled */ diff --git a/app/components/UI/Perps/utils/abTesting/tests.ts b/app/components/UI/Perps/utils/abTesting/tests.ts index 711a936bb57..c6b0f6b38f1 100644 --- a/app/components/UI/Perps/utils/abTesting/tests.ts +++ b/app/components/UI/Perps/utils/abTesting/tests.ts @@ -12,7 +12,7 @@ import type { ABTestConfig, ButtonColorTestVariants } from './types'; /** - * TAT-1937: Long/Short Button Color Test + * Long/Short Button Color Test * * Tests the impact of button colors on trading behavior: * - Control: Traditional green (long) / red (short) - familiar and intuitive diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts index d8896791da5..b4e4c75813a 100644 --- a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts @@ -1,9 +1,10 @@ /** * Helpers for mapping Mobile navigation / deeplink context onto the - * perps-controller analytics attribution contract (TAT-3463). + * perps-controller analytics attribution contract. */ import type { + PerpsAnalyticsProperties, PerpsAttributionContext, TrackingData, } from '@metamask/perps-controller'; @@ -84,3 +85,12 @@ export function setPerpsUtmAttribution(context: PerpsAttributionContext): void { } 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. + */ +export function getPerpsUtmAttributionProperties(): PerpsAnalyticsProperties { + return Engine.context.PerpsController.mergeAttributionContext(); +} 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 9a2882efbfc..ec21078fd19 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -631,7 +631,7 @@ enum EVENT_NAME { PERPS_RISK_MANAGEMENT = 'Perp Risk Management', PERPS_ERROR = 'Perp Error', PERPS_ACCOUNT_SETUP = 'Perp Account Setup', - // TAT-3463 controller contract additions (PerpsAnalyticsEvent) + // controller contract additions (PerpsAnalyticsEvent) PERPS_TRANSACTION_CONSIDERED = 'Perp Transaction Considered', PERPS_TRADE_QUOTE_RECEIVED = 'Perp Trade Quote Received', PERPS_SEARCH_QUERY = 'Perp Search Query', diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts index a45881b1ae5..5d33c56d1db 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handlePerpsUrl.ts @@ -269,7 +269,7 @@ export const handlePerpsUrl = async ({ perpsPath }: HandlePerpsUrlParams) => { perpsPath, ); - // Propagate UTM params into controller attribution context (TAT-3463). + // Propagate UTM params into controller attribution context. try { setPerpsUtmAttribution(parsePerpsUtmFromPath(perpsPath)); // Attribution is best-effort: Engine/controller may be unavailable during diff --git a/docs/perps/perps-ab-testing.md b/docs/perps/perps-ab-testing.md index fe8a716635e..d058cf0e329 100644 --- a/docs/perps/perps-ab-testing.md +++ b/docs/perps/perps-ab-testing.md @@ -270,7 +270,7 @@ const { variant, variantName, isEnabled } = usePerpsABTest({ ### Why Flat Properties? -To support multiple AB tests running simultaneously (e.g., TAT-1937 button colors, TAT-1940 asset CTA, TAT-1827 homepage CTA), we use **flat properties per test** instead of generic properties. +To support multiple AB tests running simultaneously (e.g., button colors, asset CTA, homepage CTA), we use **flat properties per test** instead of generic properties. > **Note:** For the complete event property definitions and tracking patterns, see [Perps MetaMetrics Reference](./perps-metametrics-reference.md#multiple-concurrent-tests). @@ -323,9 +323,9 @@ export const PerpsEventProperties = { // A/B testing properties (flat per test for multiple concurrent tests) // Only include AB test properties when test is enabled (event not sent when disabled) - // Button color test (TAT-1937) + // Button color test AB_TEST_BUTTON_COLOR: 'ab_test_button_color', - // Asset CTA test (TAT-1940) + // Asset CTA test AB_TEST_ASSET_CTA: 'ab_test_asset_cta', // Future tests: add as AB_TEST_{TEST_NAME} (no _ENABLED property needed) } as const; @@ -396,7 +396,7 @@ const handleLongPress = () => { - Screen views alone = exposure but not engagement - Button presses alone = engagement but no baseline for comparison -**Example Flow (TAT-1937):** +**Example Flow:** 1. User views PerpsMarketDetailsView → `PERPS_SCREEN_VIEWED` with `ab_test_button_color: 'control'` (baseline) 2. User taps Long button → `PERPS_UI_INTERACTION` with `ab_test_button_color: 'control'`, `interaction_type: 'tap'` (engagement) @@ -562,7 +562,7 @@ Check console logs for: Segment uses a Tracking Plan (schema) to validate incoming events. Properties not defined in the schema are automatically removed by Segment Protocols. This is a data governance feature, not a bug. -**Real Example (TAT-1937 Button Color Test):** +**Real Example (Button Color Test):** The app correctly sent events with `ab_test_button_color`: @@ -614,7 +614,7 @@ The `protocols.omitted` array confirms the property was sent but rejected by sch ```yaml ab_test_button_color: type: string - description: 'Button color A/B test variant (TAT-1937)' + description: 'Button color A/B test variant' required: false enum: - control @@ -626,7 +626,7 @@ The `protocols.omitted` array confirms the property was sent but rejected by sch ```yaml ab_test_button_color: type: string - description: 'Button color A/B test variant (TAT-1937). Values: control (green/red buttons) or monochrome (white/white buttons).' + description: 'Button color A/B test variant. Values: control (green/red buttons) or monochrome (white/white buttons).' required: false enum: - control @@ -673,9 +673,9 @@ For A/B test properties in Segment schema: **Examples:** -- `ab_test_button_color` - Button color test (TAT-1937) -- `ab_test_asset_cta` - Asset CTA test (TAT-1940) -- `ab_test_homepage_cta` - Homepage CTA test (TAT-1827) +- `ab_test_button_color` - Button color test +- `ab_test_asset_cta` - Asset CTA test +- `ab_test_homepage_cta` - Homepage CTA test --- @@ -683,14 +683,14 @@ For A/B test properties in Segment schema: This section guides you through creating a Mixpanel dashboard to analyze A/B test results. -**Reference Dashboard:** [Perps A/B Test - Button Color (TAT-1937)](https://mixpanel.com/project/2697051/view/3233695/app/boards#id=10912360) +**Reference Dashboard:** [Perps A/B Test - Button Color](https://mixpanel.com/project/2697051/view/3233695/app/boards#id=10912360) ### Creating the Dashboard #### 1. Create New Dashboard - Navigate to Mixpanel → Boards → + New Board -- **Name:** `Perps A/B Test - Button Color (TAT-1937)` +- **Name:** `Perps A/B Test - Button Color` - **Description:** `Measures impact of button colors on trading engagement` #### 2. Add Exposure Report (Baseline) diff --git a/docs/perps/perps-feature-flags.md b/docs/perps/perps-feature-flags.md index c127a3e89c4..1a4026cc20d 100644 --- a/docs/perps/perps-feature-flags.md +++ b/docs/perps/perps-feature-flags.md @@ -179,9 +179,9 @@ Follow existing test patterns covering: ### A/B Test Flags -| Redux Property | LaunchDarkly Key | Variants | Purpose | -| ------------------------ | --------------------------- | ----------------------- | -------------------------------- | -| `perpsAbtestButtonColor` | `perps-abtest-button-color` | `control`, `monochrome` | Button color A/B test (TAT-1937) | +| Redux Property | LaunchDarkly Key | Variants | Purpose | +| ------------------------ | --------------------------- | ----------------------- | --------------------- | +| `perpsAbtestButtonColor` | `perps-abtest-button-color` | `control`, `monochrome` | Button color A/B test | ### Configuration Flags diff --git a/docs/perps/perps-metametrics-reference.md b/docs/perps/perps-metametrics-reference.md index 7f8e5d8e166..780dcf64af6 100644 --- a/docs/perps/perps-metametrics-reference.md +++ b/docs/perps/perps-metametrics-reference.md @@ -359,7 +359,7 @@ this.#getMetrics().trackPerpsEvent(PerpsAnalyticsEvent.TradeTransaction, { ### Flat Property Pattern -To support multiple AB tests running concurrently (e.g., TAT-1937 button colors, TAT-1940 asset CTA, TAT-1827 homepage CTA), we use **flat properties** instead of generic properties. +To support multiple AB tests running concurrently (e.g., button colors, asset CTA, homepage CTA), we use **flat properties** instead of generic properties. **Property Naming:** `ab_test_{test_name}` (no `_enabled` suffix needed) @@ -383,15 +383,15 @@ usePerpsEventTracking({ [PERPS_EVENT_PROPERTY.SCREEN_TYPE]: PERPS_EVENT_VALUE.SCREEN_TYPE.ASSET_DETAILS, [PERPS_EVENT_PROPERTY.ASSET]: 'BTC', - // Test 1: Button color test (TAT-1937) - only included when enabled + // Test 1: Button color test - only included when enabled ...(isButtonColorTestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_BUTTON_COLOR]: buttonColorVariant, }), - // Test 2: Asset CTA test (TAT-1940) - future + // Test 2: Asset CTA test - future ...(isAssetCTATestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_ASSET_CTA]: assetCTAVariant, }), - // Test 3: Homepage CTA test (TAT-1827) - future + // Test 3: Homepage CTA test - future ...(isHomepageCTATestEnabled && { [PERPS_EVENT_PROPERTY.AB_TEST_HOMEPAGE_CTA]: homepageCTAVariant, }), From f2ec9369b3b4f93c7a5f9544dda9bb56fc8f0c78 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 11 Jul 2026 01:29:09 +0800 Subject: [PATCH 12/25] fix(perps): emit cached pay quote analytics --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 3e350410a00..88bde448723 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -799,12 +799,15 @@ const PerpsOrderViewContentBase: React.FC = ({ ]); // Emit "trade quote received" when a pay-with-token relay quote - // request completes (loading transitions true -> false). Only meaningful for - // the trade-with-token flow; the start time is captured when loading begins. + // 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; } @@ -819,33 +822,49 @@ const PerpsOrderViewContentBase: React.FC = ({ return; } - // isPayTotalsLoading === false: emit once when a tracked quote completes. - if (payQuoteStartRef.current !== null) { - const latencyMs = Date.now() - payQuoteStartRef.current; - payQuoteStartRef.current = null; + const blockingNoQuoteAlert = noQuotesAlerts.find((a) => a.isBlocking); + const succeeded = Boolean(payTotals) && !blockingNoQuoteAlert; - const blockingNoQuoteAlert = noQuotesAlerts.find((a) => a.isBlocking); - const succeeded = Boolean(payTotals) && !blockingNoQuoteAlert; + if (!payTotals && !blockingNoQuoteAlert) { + return; + } - 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; - } + const quoteKey = JSON.stringify({ + asset: orderForm.asset, + payTotals, + errorKey: blockingNoQuoteAlert?.key, + errorMessage: blockingNoQuoteAlert?.message, + }); + + if (lastEmittedPayQuoteKeyRef.current === quoteKey) { + 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); } + track(MetaMetricsEvents.PERPS_TRADE_QUOTE_RECEIVED, quoteProps); }, [ isPayTotalsLoading, hasCustomTokenSelected, From 2988a897c04a7a08798b4ae2ad89fdd49ba8d6fc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 18:48:39 +0100 Subject: [PATCH 13/25] test(perps): cover transaction-considered and trade-quote-received events Add happy-path coverage for PERPS_TRANSACTION_CONSIDERED (1s debounce fires once with the expected props after a filled order form settles) and PERPS_TRADE_QUOTE_RECEIVED (pay-token quote loading true to false emits with quote_latency_ms). --- .../PerpsOrderView/PerpsOrderView.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index dae44b7c6d5..90758310973 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -480,6 +480,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', () => ({ @@ -4439,6 +4453,91 @@ describe('PerpsOrderView', () => { }); }); + 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), + }), + ); + }); + }); + describe('abandon order tracking', () => { let captured: { eventName: unknown; props: Record }[]; From 1b9656fe1a2774409a497e539ea0cf6217e77413 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 11 Jul 2026 02:28:36 +0800 Subject: [PATCH 14/25] fix(perps): reset pay quote latency timer --- app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 88bde448723..8a8e9285425 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -826,6 +826,7 @@ const PerpsOrderViewContentBase: React.FC = ({ const succeeded = Boolean(payTotals) && !blockingNoQuoteAlert; if (!payTotals && !blockingNoQuoteAlert) { + payQuoteStartRef.current = null; return; } @@ -837,6 +838,7 @@ const PerpsOrderViewContentBase: React.FC = ({ }); if (lastEmittedPayQuoteKeyRef.current === quoteKey) { + payQuoteStartRef.current = null; return; } From 4251fab8f7bc034510541ceb168c44e2e1a25a72 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 20:52:57 +0100 Subject: [PATCH 15/25] fix(perps): report close-position entry action in screen-view The position_close screen-view derived button_clicked from isPartialClose, but closePercentage defaults to 100 at open, so it always reported 'close' and never 'reduce_exposure'. Thread the entry CTA (button_clicked + button_location) through the close-position navigation route param from each entry point (reduce-exposure passes 'reduce_exposure', plain close passes 'close') and use it for the initial screen-view. isPartialClose still drives later interaction events. --- .../PerpsClosePositionView.test.tsx | 63 +++++++++++++++++++ .../PerpsClosePositionView.tsx | 20 ++++-- .../PerpsMarketDetailsView.tsx | 4 ++ .../PerpsOrderBookView.test.tsx | 4 ++ .../PerpsOrderBookView/PerpsOrderBookView.tsx | 4 ++ .../PerpsSelectModifyActionView.test.tsx | 4 ++ .../PerpsSelectModifyActionView.tsx | 6 +- .../UI/Perps/hooks/usePerpsNavigation.ts | 19 +++++- app/components/UI/Perps/types/navigation.ts | 2 + 9 files changed, 117 insertions(+), 9 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index 5900af9f4ee..d9355797285 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -3362,4 +3362,67 @@ describe('PerpsClosePositionView', () => { ); }); }); + + 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 when opened via the reduce-exposure entry', () => { + useRouteMock.mockReturnValue({ + params: { + position: defaultPerpsPositionMock, + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, + }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [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 when opened via a plain close entry', () => { + useRouteMock.mockReturnValue({ + params: { + position: defaultPerpsPositionMock, + buttonClicked: PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + buttonLocation: PERPS_EVENT_VALUE.BUTTON_LOCATION.ORDER_BOOK, + }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [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 when no entry action is provided', () => { + useRouteMock.mockReturnValue({ + params: { position: defaultPerpsPositionMock }, + }); + + renderWithProvider(); + + expectScreenViewed({ + [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: + PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + }); + }); + }); }); diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index 5abf028a714..83cabe8961f 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -88,9 +88,16 @@ 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'); @@ -346,11 +353,14 @@ const PerpsClosePositionView: React.FC = () => { [PERPS_EVENT_PROPERTY.UNREALIZED_PNL_PERCENT]: unrealizedPnlPercent, [PERPS_EVENT_PROPERTY.SOURCE]: PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [PERPS_EVENT_PROPERTY.RECEIVED_AMOUNT]: receiveAmount, - [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: isPartialClose - ? PERPS_EVENT_VALUE.BUTTON_CLICKED.REDUCE_EXPOSURE - : PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, + // 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]: - PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, + entryButtonLocation ?? PERPS_EVENT_VALUE.BUTTON_LOCATION.SCREEN, }, }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 85a8f161da4..aad9e7cf391 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -1018,6 +1018,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]); diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx index ade4b24e521..82673e73272 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.test.tsx @@ -890,6 +890,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 6e7a629d9b6..d3309b228c4 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -580,6 +580,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/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/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/types/navigation.ts b/app/components/UI/Perps/types/navigation.ts index ec2465ac29c..8f54fc4d121 100644 --- a/app/components/UI/Perps/types/navigation.ts +++ b/app/components/UI/Perps/types/navigation.ts @@ -131,6 +131,8 @@ export type PerpsNavigationParamList = { PerpsClosePosition: { position: Position; source?: string; + buttonClicked?: string; + buttonLocation?: string; }; PerpsAdjustMargin: { From 76983472496c36656851467f3ddaa4b94021fa27 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 21:05:15 +0100 Subject: [PATCH 16/25] fix(perps): derive place-order trade action for flips and increases The order-form place_order and transaction_considered events hardcoded the transaction action to create_position / increase_exposure, so adding to an existing position and reversing it (opposite-side flip) were mislabelled. Derive the action from the existing position direction and the order direction so create_position, increase_exposure, flip_long_to_short and flip_short_to_long are all emitted correctly, and keep the considered and executed events in agreement. --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 14 ++++-- .../UI/Perps/utils/deriveTradeAction.test.ts | 42 ++++++++++++++++++ .../UI/Perps/utils/deriveTradeAction.ts | 43 +++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 app/components/UI/Perps/utils/deriveTradeAction.test.ts create mode 100644 app/components/UI/Perps/utils/deriveTradeAction.ts diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 8a8e9285425..6c5ddc229b1 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -140,6 +140,7 @@ import { PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { willFlipPosition } from '../../utils/orderUtils'; +import { derivePerpsTradeAction } from '../../utils/deriveTradeAction'; import { toPerpsEntryAttribution } from '../../utils/perpsAnalyticsAttribution'; import { calculateRoEForPrice, @@ -763,7 +764,10 @@ const PerpsOrderViewContentBase: React.FC = ({ // 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]: PERPS_EVENT_VALUE.ACTION.CREATE_POSITION, + [PERPS_EVENT_PROPERTY.ACTION]: derivePerpsTradeAction( + currentMarketPosition, + orderForm.direction, + ), [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderSize, [PERPS_EVENT_PROPERTY.INPUT_METHOD]: inputMethodRef.current, [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, @@ -793,6 +797,7 @@ const PerpsOrderViewContentBase: React.FC = ({ orderForm.leverage, orderForm.takeProfitPrice, orderForm.stopLossPrice, + currentMarketPosition, hasCustomTokenSelected, payToken, track, @@ -1414,9 +1419,10 @@ const PerpsOrderViewContentBase: React.FC = ({ ...(feeResults.protocolFeeRate !== undefined ? { hlFeeRate: feeResults.protocolFeeRate } : {}), - tradeAction: currentMarketPosition - ? 'increase_exposure' - : 'create_position', + tradeAction: derivePerpsTradeAction( + currentMarketPosition, + orderForm.direction, + ), tradeWithToken: hasCustomTokenSelected, ...(hasCustomTokenSelected && payToken && { 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; +} From dcda1f9911a4263cc445239445c549f00445f58d Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 21:14:18 +0100 Subject: [PATCH 17/25] fix(perps): order search funnel and note controller-owned deferrals Keep the market-search funnel correctly ordered and lossless: - Flush the pending debounced search query before emitting the result-tap event so the stream is always query then tap, never tap then query. - On blur/unmount while the markets list is still loading, emit the pending search query with the count-dependent props omitted (unknown mid-load) instead of dropping it silently or reporting a stale count. Also document two controller-owned deferrals so the client does not re-emit and double-count: open-trade partial-fill status lands with the next perps-controller release (like order_execution_latency_ms), and note that session-lifetime UTM is intended last-touch attribution. --- .../PerpsMarketListView.test.tsx | 92 ++++++++++++++++++- .../PerpsMarketListView.tsx | 71 ++++++++++---- .../UI/Perps/hooks/usePerpsOrderExecution.ts | 6 ++ .../Perps/utils/perpsAnalyticsAttribution.ts | 4 + 4 files changed, 149 insertions(+), 24 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 7eb2212bc72..1e63a17d102 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -2061,7 +2061,7 @@ describe('PerpsMarketListView', () => { jest.useRealTimers(); }); - it('does NOT flush a pending search query on blur while results are still loading', () => { + it('flushes a pending search query on blur while loading with the count props omitted', () => { jest.useFakeTimers(); mockUsePerpsMarketListView.mockReturnValue({ @@ -2098,7 +2098,8 @@ describe('PerpsMarketListView', () => { forex: 0, new: 0, }, - // Results still loading — a mid-load flush would report a stale count. + // 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, }); @@ -2120,11 +2121,92 @@ describe('PerpsMarketListView', () => { focusCleanups.forEach((cleanup) => cleanup()); }); - // The pending query is dropped (not emitted as settled) while loading. - expect(mockTrack).not.toHaveBeenCalledWith( + // 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.anything(), + 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), + }), + ); + + 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(), + }, + 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(); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index a30c28f50bb..3d07c60e4f9 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -147,6 +147,12 @@ const PerpsMarketListView = ({ // 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. + }); // Handler for market press (defined early to avoid use-before-define) const handleMarketPress = useCallback( @@ -165,6 +171,10 @@ const PerpsMarketListView = ({ 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]: filteredMarkets.length, @@ -295,10 +305,20 @@ const PerpsMarketListView = ({ // 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) => void>(() => { + const emitSearchQueryRef = useRef< + (trimmedQuery: string, resultsSettled?: boolean) => void + >(() => { // Assigned on every render below. }); - emitSearchQueryRef.current = (trimmedQuery: string) => { + // `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 = filteredMarkets.length; const hasResults = resultCount > 0; @@ -317,29 +337,39 @@ const PerpsMarketListView = ({ : 'browse'; lastEmittedSearchQueryRef.current = normalizedQuery; - lastEmittedSearchResultsCountRef.current = resultCount; + if (resultsSettled) { + lastEmittedSearchResultsCountRef.current = resultCount; + } searchQueryCountRef.current += 1; track(MetaMetricsEvents.PERPS_SEARCH_QUERY, { [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: normalizedQuery, query_text: normalizedQuery, query_length: normalizedQuery.length, - [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: resultCount, - [PERPS_EVENT_PROPERTY.RESULT_COUNT]: resultCount, - has_results: hasResults, + ...(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, }); - 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, - }); + // 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. @@ -362,21 +392,24 @@ const PerpsMarketListView = ({ }, []); // Flush a query still awaiting the debounce so a blur/unmount records it - // before we decide on abandonment — it is never silently lost. A query whose - // results are still loading is dropped (not emitted as settled), matching the - // debounce gate, so result_count/has_results are never captured mid-load. + // 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) { - if (!isLoadingMarketsRef.current) { - emitSearchQueryRef.current(pendingSearchQueryRef.current); - } + emitSearchQueryRef.current( + pendingSearchQueryRef.current, + !isLoadingMarketsRef.current, + ); pendingSearchQueryRef.current = null; } }, []); + flushPendingSearchQueryRef.current = flushPendingSearchQuery; const emitSearchAbandoned = useCallback(() => { if (!lastEmittedSearchQueryRef.current) { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index 3213f4f5a5a..27ee7fa8a2e 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -62,6 +62,12 @@ const getPerpsOrderPositionSnapshot = ( * 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 = {}, diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts index b4e4c75813a..2626b7a01e8 100644 --- a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts @@ -78,6 +78,10 @@ export function hasPerpsUtmAttribution( /** * 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)) { From b28acca0da21a94f8c0f0b6e1ccce58406742951 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 21:23:34 +0100 Subject: [PATCH 18/25] fix(perps): make UTM attribution enrichment best-effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPerpsUtmAttributionProperties runs inside every client PERPS_SCREEN_VIEWED build. A throwing Engine/controller lookup previously took the whole screen-view emit down with it. Wrap the merge in try/catch: on failure log via DevLogger and return no UTM props so the screen view still emits — the sanctioned exception to no-swallowed-exceptions (expected, recoverable, logged), mirroring the best-effort handling in handlePerpsUrl. Also document that the trackingData TradeAction cast in PerpsOrderView is only needed for the narrow installed controller type and drops once the next release widens it to include the flip values. --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 4 +++ .../utils/perpsAnalyticsAttribution.test.ts | 30 +++++++++++++++++++ .../Perps/utils/perpsAnalyticsAttribution.ts | 17 ++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 6c5ddc229b1..e2b6ee36688 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -1431,6 +1431,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'], }; diff --git a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts index 6dc3bd9a790..2deab5ad655 100644 --- a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.test.ts @@ -1,11 +1,14 @@ 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, @@ -14,11 +17,18 @@ jest.mock('../../../../core/Engine', () => ({ 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(); @@ -90,4 +100,24 @@ describe('perpsAnalyticsAttribution', () => { 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 index 2626b7a01e8..69a51a86629 100644 --- a/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts +++ b/app/components/UI/Perps/utils/perpsAnalyticsAttribution.ts @@ -9,6 +9,7 @@ import type { TrackingData, } from '@metamask/perps-controller'; import Engine from '../../../../core/Engine'; +import DevLogger from '../../../../core/SDKConnect/utils/DevLogger'; export interface PerpsEntryAttributionInput { source?: string; @@ -94,7 +95,21 @@ export function setPerpsUtmAttribution(context: PerpsAttributionContext): void { * 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 { - return Engine.context.PerpsController.mergeAttributionContext(); + try { + return Engine.context.PerpsController.mergeAttributionContext(); + } catch (error) { + DevLogger.log( + '[perpsAnalyticsAttribution] UTM attribution lookup failed; emitting without UTM props', + error, + ); + return {}; + } } From 213e3f919680f16f3e568027cd3f4f0e6f355c20 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Sat, 11 Jul 2026 04:30:00 +0800 Subject: [PATCH 19/25] fix(perps): dedupe analytics edge cases --- .../PerpsMarketListView.tsx | 23 +++++++++++++++---- .../Views/PerpsOrderView/PerpsOrderView.tsx | 17 ++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 3d07c60e4f9..fe59260dc64 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -144,6 +144,7 @@ const PerpsMarketListView = ({ // 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); @@ -337,8 +338,9 @@ const PerpsMarketListView = ({ : 'browse'; lastEmittedSearchQueryRef.current = normalizedQuery; + lastEmittedSearchResultsCountRef.current = resultsSettled ? resultCount : 0; if (resultsSettled) { - lastEmittedSearchResultsCountRef.current = resultCount; + flushedUnsettledSearchQueryRef.current = null; } searchQueryCountRef.current += 1; @@ -385,6 +387,7 @@ const PerpsMarketListView = ({ searchResultTimerRef.current = null; } pendingSearchQueryRef.current = null; + flushedUnsettledSearchQueryRef.current = null; lastEmittedSearchQueryRef.current = ''; lastEmittedSearchResultsCountRef.current = 0; searchStartTimeRef.current = null; @@ -402,10 +405,11 @@ const PerpsMarketListView = ({ searchResultTimerRef.current = null; } if (pendingSearchQueryRef.current) { - emitSearchQueryRef.current( - pendingSearchQueryRef.current, - !isLoadingMarketsRef.current, - ); + const resultsSettled = !isLoadingMarketsRef.current; + emitSearchQueryRef.current(pendingSearchQueryRef.current, resultsSettled); + flushedUnsettledSearchQueryRef.current = resultsSettled + ? null + : pendingSearchQueryRef.current.toLowerCase(); pendingSearchQueryRef.current = null; } }, []); @@ -448,6 +452,10 @@ const PerpsMarketListView = ({ 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 @@ -458,6 +466,11 @@ const PerpsMarketListView = ({ return; } + if (flushedUnsettledSearchQueryRef.current === normalizedQuery) { + pendingSearchQueryRef.current = null; + return; + } + searchResultTimerRef.current = setTimeout(() => { emitSearchQueryRef.current(trimmedQuery); pendingSearchQueryRef.current = null; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index e2b6ee36688..afa591b20ab 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -750,6 +750,16 @@ 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. @@ -764,10 +774,7 @@ const PerpsOrderViewContentBase: React.FC = ({ // 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]: derivePerpsTradeAction( - currentMarketPosition, - orderForm.direction, - ), + [PERPS_EVENT_PROPERTY.ACTION]: consideredTradeAction, [PERPS_EVENT_PROPERTY.ORDER_SIZE]: orderSize, [PERPS_EVENT_PROPERTY.INPUT_METHOD]: inputMethodRef.current, [PERPS_EVENT_PROPERTY.ASSET]: orderForm.asset, @@ -797,7 +804,7 @@ const PerpsOrderViewContentBase: React.FC = ({ orderForm.leverage, orderForm.takeProfitPrice, orderForm.stopLossPrice, - currentMarketPosition, + consideredTradeAction, hasCustomTokenSelected, payToken, track, From 892b4f03fb57681a949e50601649522f8f0ecb32 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Fri, 10 Jul 2026 21:38:03 +0100 Subject: [PATCH 20/25] fix(perps): honour route source and count each quote/abandon session - position_close screen view now emits the route-provided source (reduce exposure -> position_screen, order book -> order_book) instead of always perp_asset_screen, falling back to the asset screen for direct entries. - The trade-quote dedupe key now includes the amount and pay-token identity so a retried failed quote with the same blocking alert (which carries no payTotals) counts as a distinct attempt instead of being deduped away. - The abandon-order tracker resets the caller's committed flag on each focus so a commit from a prior session on a reused screen no longer suppresses abandon tracking for the next one. --- .../PerpsClosePositionView.test.tsx | 12 ++++-- .../PerpsClosePositionView.tsx | 6 ++- .../PerpsOrderView/PerpsOrderView.test.tsx | 41 +++++++++++++++++++ .../Views/PerpsOrderView/PerpsOrderView.tsx | 10 +++++ .../usePerpsAbandonOrderTracking.test.ts | 17 +++++++- .../hooks/usePerpsAbandonOrderTracking.ts | 7 +++- 6 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index d9355797285..02cf14045d4 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -3374,10 +3374,11 @@ describe('PerpsClosePositionView', () => { }), ); - it('reports button_clicked=reduce_exposure when opened via the reduce-exposure entry', () => { + 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, }, @@ -3386,6 +3387,7 @@ describe('PerpsClosePositionView', () => { 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]: @@ -3393,10 +3395,11 @@ describe('PerpsClosePositionView', () => { }); }); - it('reports button_clicked=close when opened via a plain close entry', () => { + 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, }, @@ -3405,6 +3408,7 @@ describe('PerpsClosePositionView', () => { 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]: @@ -3412,7 +3416,7 @@ describe('PerpsClosePositionView', () => { }); }); - it('defaults button_clicked=close when no entry action is provided', () => { + it('defaults button_clicked=close and source=perp_asset_screen when no entry action is provided', () => { useRouteMock.mockReturnValue({ params: { position: defaultPerpsPositionMock }, }); @@ -3420,6 +3424,8 @@ describe('PerpsClosePositionView', () => { renderWithProvider(); expectScreenViewed({ + [PERPS_EVENT_PROPERTY.SOURCE]: + PERPS_EVENT_VALUE.SOURCE.PERP_ASSET_SCREEN, [PERPS_EVENT_PROPERTY.BUTTON_CLICKED]: PERPS_EVENT_VALUE.BUTTON_CLICKED.CLOSE, }); diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index 83cabe8961f..c9478c2b9ca 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -351,7 +351,11 @@ 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 diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx index 90758310973..afdba9e701d 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx @@ -4536,6 +4536,47 @@ describe('PerpsOrderView', () => { }), ); }); + + 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', () => { diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index afa591b20ab..8c4509ee852 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -842,8 +842,16 @@ const PerpsOrderViewContentBase: React.FC = ({ 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, @@ -885,6 +893,8 @@ const PerpsOrderViewContentBase: React.FC = ({ payTotals, noQuotesAlerts, orderForm.asset, + orderForm.amount, + payToken, track, ]); diff --git a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts index cb174615942..be383f1e90a 100644 --- a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts @@ -50,9 +50,10 @@ describe('usePerpsAbandonOrderTracking', () => { [PERPS_EVENT_PROPERTY.ASSET]: 'BTC', [PERPS_EVENT_PROPERTY.ACTION]: 'abandon_order', }); - return renderHook(() => + const result = renderHook(() => usePerpsAbandonOrderTracking({ getAbandonProperties, hasCommittedRef }), ); + return { ...result, hasCommittedRef }; }; beforeEach(() => { @@ -98,13 +99,25 @@ describe('usePerpsAbandonOrderTracking', () => { }); it('does NOT emit after the flow is committed (placed / confirmed)', () => { - renderTracking(true); + 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'); diff --git a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts index 5fbd8952d59..fafb6f787bc 100644 --- a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.ts @@ -72,7 +72,12 @@ export function usePerpsAbandonOrderTracking({ focusStartRef.current = Date.now(); focusDepthRef.current = getNavigationStackDepth(navigation); emittedRef.current = false; - }, [navigation]), + // 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(() => { From 752dde47247dde3b17fef360b7300484a8bf26d6 Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 14 Jul 2026 09:29:15 -0700 Subject: [PATCH 21/25] fix: lint tsc --- .../Views/PerpsOrderView/PerpsOrderView.tsx | 3 - .../hooks/usePerpsOrderExecution.test.ts | 157 ------------------ .../UI/Perps/hooks/usePerpsOrderExecution.ts | 136 --------------- 3 files changed, 296 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index b88dae446d3..fd5960a3022 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -1484,9 +1484,6 @@ const PerpsOrderViewContentBase: React.FC = ({ orderForm.direction, ), chartLibrary, - tradeAction: currentMarketPosition - ? 'increase_exposure' - : 'create_position', tradeWithToken: hasCustomTokenSelected, ...(hasCustomTokenSelected && payToken && { diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts index f9d2fb4ab81..9caae0d0dc2 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts @@ -12,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', () => { @@ -554,9 +550,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -576,55 +569,6 @@ describe('usePerpsOrderExecution', () => { expect(onSuccess).toHaveBeenCalledWith(mockPosition); expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); - 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, - }), - ); }); }); @@ -695,9 +639,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -718,55 +659,6 @@ describe('usePerpsOrderExecution', () => { expect(onError).toHaveBeenCalledWith('Insufficient margin'); expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); - 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, - }), - ); }); it('calls onError with unknown error when order returns success false without error', async () => { @@ -819,9 +711,6 @@ describe('usePerpsOrderExecution', () => { tradeWithToken: true, mmPayTokenSelected: 'USDC', mmPayNetworkSelected: 'ethereum', - chartLibrary: 'advanced', - } as NonNullable & { - chartLibrary: string; }, }; @@ -839,52 +728,6 @@ describe('usePerpsOrderExecution', () => { expect(onError).toHaveBeenCalledWith('Network timeout'); expect(mockPlaceOrder).toHaveBeenCalledWith(paramsWithTracking); - 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, - }), - ); }); }); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts index 8b6807ccc90..27ee7fa8a2e 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.ts @@ -31,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. */ @@ -186,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); @@ -219,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 @@ -383,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) { @@ -459,41 +358,6 @@ 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); From a78be1dba8e8e4a4fd36027627b3ac6ec3d6decf Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 14 Jul 2026 10:36:22 -0700 Subject: [PATCH 22/25] fix: lint --- .../Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 51e89bd68d2..0032c0bbb40 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -715,6 +715,7 @@ const PerpsMarketDetailsView: React.FC = () => { source_section, existingPosition, openOrders.length, + isWatchlist, isPerpsInsightsEnabled, perpsInsightsReport, isServiceInterruptionBannerEnabled, From fb648b84c272c66f7008678fde54411eb71428bd Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 14 Jul 2026 11:06:52 -0700 Subject: [PATCH 23/25] fix: lint and unit test --- .../PerpsMarketListView.test.tsx | 21 +++++++++++++++++++ .../usePerpsAbandonOrderTracking.test.ts | 6 ++++++ .../hooks/usePerpsOrderExecution.test.ts | 9 ++++++++ 3 files changed, 36 insertions(+) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index 36cffb4b66f..bf79bb52467 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -753,6 +753,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', () => { @@ -2035,6 +2038,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 0, stocks: 0, @@ -2103,6 +2109,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 1, stocks: 0, @@ -2174,6 +2183,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 0, stocks: 0, @@ -2263,6 +2275,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 1, stocks: 0, @@ -2324,6 +2339,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 1, stocks: 0, @@ -2398,6 +2416,9 @@ describe('PerpsMarketListView', () => { marketTypeFilter: 'all' as const, setMarketTypeFilter: jest.fn(), }, + recentlyViewedState: { + recentlyViewedMarketObjects: [], + }, marketCounts: { crypto: 1, stocks: 0, diff --git a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts index be383f1e90a..d31d89711e2 100644 --- a/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsAbandonOrderTracking.test.ts @@ -67,6 +67,12 @@ describe('usePerpsAbandonOrderTracking', () => { } 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'); diff --git a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts index 9caae0d0dc2..7d6a55e9362 100644 --- a/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts +++ b/app/components/UI/Perps/hooks/usePerpsOrderExecution.test.ts @@ -313,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(); From 39902b138d74cc97a2baa651d7f55679ac7827eb Mon Sep 17 00:00:00 2001 From: Nicholas Gambino Date: Tue, 14 Jul 2026 12:32:29 -0700 Subject: [PATCH 24/25] fix: incorrect tap metrics --- .../PerpsMarketListView.test.tsx | 91 +++++++++++++- .../PerpsMarketListView.tsx | 116 +++++++++++++----- 2 files changed, 175 insertions(+), 32 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx index bf79bb52467..f01225b4e92 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx @@ -443,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', @@ -2245,6 +2251,19 @@ describe('PerpsMarketListView', () => { }), ); + // 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(); }); @@ -2312,6 +2331,74 @@ describe('PerpsMarketListView', () => { 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(); diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx index 9ef26a6ffcd..5dc7a71c3f5 100644 --- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.tsx @@ -64,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, @@ -148,7 +150,11 @@ const PerpsMarketListView = ({ null, ); const lastEmittedSearchQueryRef = useRef(''); - const lastEmittedSearchResultsCountRef = useRef(0); + // 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 @@ -167,6 +173,61 @@ const PerpsMarketListView = ({ // 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) => { @@ -183,7 +244,8 @@ const PerpsMarketListView = ({ } else if (trimmedQuery) { source_section = PERPS_EVENT_VALUE.SOURCE_SECTION.ACTIVE_SEARCH; const resultRank = - filteredMarkets.findIndex((m) => m.symbol === market.symbol) + 1; + visibleSearchResults.findIndex((m) => m.symbol === market.symbol) + + 1; const timeToTapMs = searchStartTimeRef.current !== null ? Date.now() - searchStartTimeRef.current @@ -194,7 +256,7 @@ const PerpsMarketListView = ({ flushPendingSearchQueryRef.current(); track(MetaMetricsEvents.PERPS_SEARCH_RESULT_TAPPED, { [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: trimmedQuery.toLowerCase(), - [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: filteredMarkets.length, + [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: visibleSearchResults.length, ...(resultRank > 0 ? { [PERPS_EVENT_PROPERTY.RESULT_RANK]: resultRank } : {}), @@ -239,7 +301,7 @@ const PerpsMarketListView = ({ searchQuery, showFavoritesOnly, marketTypeFilter, - filteredMarkets, + visibleSearchResults, track, ], ); @@ -337,7 +399,7 @@ const PerpsMarketListView = ({ resultsSettled = true, ) => { const normalizedQuery = trimmedQuery.toLowerCase(); - const resultCount = filteredMarkets.length; + const resultCount = visibleSearchResults.length; const hasResults = resultCount > 0; const activeChips = [ ...(showFavoritesOnly @@ -354,7 +416,9 @@ const PerpsMarketListView = ({ : 'browse'; lastEmittedSearchQueryRef.current = normalizedQuery; - lastEmittedSearchResultsCountRef.current = resultsSettled ? resultCount : 0; + lastEmittedSearchResultsCountRef.current = resultsSettled + ? resultCount + : undefined; if (resultsSettled) { flushedUnsettledSearchQueryRef.current = null; } @@ -405,7 +469,7 @@ const PerpsMarketListView = ({ pendingSearchQueryRef.current = null; flushedUnsettledSearchQueryRef.current = null; lastEmittedSearchQueryRef.current = ''; - lastEmittedSearchResultsCountRef.current = 0; + lastEmittedSearchResultsCountRef.current = undefined; searchStartTimeRef.current = null; searchQueryCountRef.current = 0; }, []); @@ -437,15 +501,21 @@ const PerpsMarketListView = ({ } track(MetaMetricsEvents.PERPS_SEARCH_ABANDONED, { [PERPS_EVENT_PROPERTY.SEARCH_QUERY]: lastEmittedSearchQueryRef.current, - [PERPS_EVENT_PROPERTY.RESULTS_COUNT]: - lastEmittedSearchResultsCountRef.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 = 0; + lastEmittedSearchResultsCountRef.current = undefined; }, [track]); // Debounced Search Query tracking — fires ~500ms after the query stabilises. @@ -501,7 +571,7 @@ const PerpsMarketListView = ({ }, [ searchQuery, isLoadingMarkets, - filteredMarkets.length, + visibleSearchResults.length, emitSearchAbandoned, resetSearchSession, ]); @@ -608,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 ( Date: Wed, 15 Jul 2026 09:33:07 -0700 Subject: [PATCH 25/25] chore: address merge conflicts --- .../PerpsClosePositionView.test.tsx | 323 +++++------------- 1 file changed, 84 insertions(+), 239 deletions(-) diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx index 684cfee5b15..9e830389371 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, - ORDER_SLIPPAGE_CONFIG + ORDER_SLIPPAGE_CONFIG, } from '@metamask/perps-controller'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import React from 'react'; @@ -1998,7 +1998,6 @@ describe('PerpsClosePositionView', () => { // HyperLiquid's marginUsed already includes PnL // receivedAmount = marginUsed - fees = 1450 - 45 = 1405 // realizedPnl = unrealizedPnl = 150 (from defaultPerpsPositionMock) -<<<<<<< HEAD expect(handleClosePosition).toHaveBeenCalledWith( expect.objectContaining({ position: defaultPerpsPositionMock, @@ -2022,167 +2021,11 @@ describe('PerpsClosePositionView', () => { slippage: { usdAmount: undefined, // undefined for full close to bypass $10 minimum validation priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders - maxSlippageBps: 300, // maxSlippageBps: 3% slippage tolerance (300 basis points) - conservative default + maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps, }, }), ); }); - - it('validates limit order requires price before confirmation', async () => { - // Test the early return in handleConfirm when limit has no price - const handleClosePosition = jest.fn(); - usePerpsClosePositionMock.mockReturnValue({ - handleClosePosition, - isClosing: false, - }); - - // Create a test component that simulates limit order without price - const TestComponent = () => { - const [orderType] = React.useState<'market' | 'limit'>('limit'); - const [limitPrice] = React.useState(''); // No price set - - return ( - { - // This simulates the handleConfirm logic that returns early - if (orderType === 'limit' && !limitPrice) { - return; // Early return - should not call handleClosePosition - } - await handleClosePosition(); - }} - > - Confirm - - ); - }; - - const { getByTestId } = render(); - - // Act - Try to confirm without limit price - fireEvent.press(getByTestId('test-confirm-no-price')); - - // Assert - Should NOT call handleClosePosition due to early return - await waitFor(() => { - expect(handleClosePosition).not.toHaveBeenCalled(); - }); - }); - - it('handles limit order confirmation with price validation', async () => { - // Arrange - const handleClosePosition = jest.fn().mockResolvedValue(undefined); - usePerpsClosePositionMock.mockReturnValue({ - handleClosePosition, - isClosing: false, - }); - - // Mock a component state with limit order and price - const TestComponent = () => { - const [orderType] = React.useState<'market' | 'limit'>('limit'); - const [limitPrice] = React.useState('50000'); - - return ( - - { - // Simulate the handleConfirm logic for limit orders - if (orderType === 'limit' && !limitPrice) { - return; // Should not proceed without price - } - await handleClosePosition({ - position: defaultPerpsPositionMock, - size: '', - orderType, - limitPrice: orderType === 'limit' ? limitPrice : undefined, - trackingData: { - totalFee: 45, - marketPrice: 3000, - receivedAmount: 1405, - realizedPnl: 150, - metamaskFeeRate: 0, - feeDiscountPercentage: undefined, - metamaskFee: 0, - estimatedPoints: undefined, - inputMethod: 'default', - }, - marketPrice: '3000.00', - slippage: { - usdAmount: '4500', - priceAtCalculation: 3000, - maxSlippageBps: 100, - }, - }); - }} - > - Confirm - - - ); - }; - - const { getByTestId } = render(); - - // Act - Press confirm for limit order - fireEvent.press(getByTestId('test-confirm')); - - // Assert - Should call with limit price and specific calculated values - await waitFor(() => { - expect(handleClosePosition).toHaveBeenCalledWith( - expect.objectContaining({ - position: defaultPerpsPositionMock, - size: '', - orderType: 'limit', - limitPrice: '50000', - 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: { - usdAmount: '4500', - priceAtCalculation: 3000, - maxSlippageBps: 100, - }, - }), - ); - }); - }); -======= - 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, - }, - }); - }); ->>>>>>> main }); describe('Keypad Interaction - Real Component', () => { @@ -3214,7 +3057,6 @@ describe('PerpsClosePositionView', () => { }); }); -<<<<<<< HEAD describe('abandon order tracking', () => { const abandonInteraction = [ MetaMetricsEvents.PERPS_UI_INTERACTION, @@ -3290,7 +3132,88 @@ describe('PerpsClosePositionView', () => { 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') @@ -3421,88 +3344,11 @@ describe('PerpsClosePositionView', () => { ).toBeNull(); // Act - confirm the close ->>>>>>> main fireEvent.press( getByTestId( PerpsClosePositionViewSelectorsIDs.CLOSE_POSITION_CONFIRM_BUTTON, ), ); -<<<<<<< HEAD - 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, - }); -======= // Assert - a market close is submitted, never a limit order behind the flag await waitFor(() => { @@ -3551,7 +3397,6 @@ describe('PerpsClosePositionView', () => { expect( queryByTestId(PerpsLimitPriceBottomSheetSelectorsIDs.CONFIRM_BUTTON), ).toBeNull(); ->>>>>>> main }); }); });