Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ebd6af7
feat(perps): consume controller analytics contract (TAT-3463)
abretonc7s Jul 9, 2026
0012e30
fix: address self-review feedback (MANUAL-000002)
abretonc7s Jul 9, 2026
57ef686
fix: forward discovery source_section into order tracking (MANUAL-000…
abretonc7s Jul 9, 2026
7581444
Merge remote-tracking branch 'origin/main' into MANUAL-000002-feat-co…
abretonc7s Jul 10, 2026
3d3171d
fix: supply required TrackingData fields on cancel analytics
abretonc7s Jul 10, 2026
b6b6ee0
fix(perps): track abandoned search result count
abretonc7s Jul 10, 2026
aed0571
test(perps): align analytics contract expectations
abretonc7s Jul 10, 2026
1522d06
test(perps): allow close tracking metadata
abretonc7s Jul 10, 2026
9db810f
test(perps): update search and flip analytics mocks
abretonc7s Jul 10, 2026
152fb4b
test(perps): allow close tracking metadata
abretonc7s Jul 10, 2026
476488f
test(perps): allow tpsl tracking metadata
abretonc7s Jul 10, 2026
5f29a13
feat(perps): complete analytics ticket coverage and discovery funnel
abretonc7s Jul 10, 2026
f2ec936
fix(perps): emit cached pay quote analytics
abretonc7s Jul 10, 2026
2988a89
test(perps): cover transaction-considered and trade-quote-received ev…
abretonc7s Jul 10, 2026
1b9656f
fix(perps): reset pay quote latency timer
abretonc7s Jul 10, 2026
4251fab
fix(perps): report close-position entry action in screen-view
abretonc7s Jul 10, 2026
7698347
fix(perps): derive place-order trade action for flips and increases
abretonc7s Jul 10, 2026
dcda1f9
fix(perps): order search funnel and note controller-owned deferrals
abretonc7s Jul 10, 2026
b28acca
fix(perps): make UTM attribution enrichment best-effort
abretonc7s Jul 10, 2026
213e3f9
fix(perps): dedupe analytics edge cases
abretonc7s Jul 10, 2026
892b4f0
fix(perps): honour route source and count each quote/abandon session
abretonc7s Jul 10, 2026
a378893
chore(perps): merge origin/main into perps analytics branch
abretonc7s Jul 11, 2026
7e5bde2
chore: merge main, address conflicts
gambinish Jul 14, 2026
752dde4
fix: lint tsc
gambinish Jul 14, 2026
a78be1d
fix: lint
gambinish Jul 14, 2026
fb648b8
fix: lint and unit test
gambinish Jul 14, 2026
39902b1
fix: incorrect tap metrics
gambinish Jul 14, 2026
6afb395
chore: merge main, address conflicts
gambinish Jul 15, 2026
6c19369
chore: address merge conflicts
gambinish Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { ORDER_SLIPPAGE_CONFIG } from '@metamask/perps-controller';
import {
PERPS_EVENT_PROPERTY,
PERPS_EVENT_VALUE,
ORDER_SLIPPAGE_CONFIG,
} from '@metamask/perps-controller';
import { MetaMetricsEvents } from '../../../../../core/Analytics';
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import {
Expand Down Expand Up @@ -151,11 +156,11 @@ jest.mock('../../components/PerpsBottomSheetTooltip', () => ({

jest.mock('../../../Rewards/components/RewardsVipBadge/RewardsVipBadge', () => {
const MockReact = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
const { View: MockView } = jest.requireActual('react-native');
return {
__esModule: true,
default: () =>
MockReact.createElement(View, { testID: 'rewards-vip-badge' }),
MockReact.createElement(MockView, { testID: 'rewards-vip-badge' }),
};
});

Expand Down Expand Up @@ -237,6 +242,7 @@ describe('PerpsClosePositionView', () => {
// Setup navigation mocks
useNavigationMock.mockReturnValue({
goBack: mockGoBack,
addListener: jest.fn(() => jest.fn()),
});

// Setup default route params
Expand Down Expand Up @@ -1992,31 +1998,33 @@ describe('PerpsClosePositionView', () => {
// HyperLiquid's marginUsed already includes PnL
// receivedAmount = marginUsed - fees = 1450 - 45 = 1405
// realizedPnl = unrealizedPnl = 150 (from defaultPerpsPositionMock)
expect(handleClosePosition).toHaveBeenCalledWith({
position: defaultPerpsPositionMock,
size: '',
orderType: 'market',
limitPrice: undefined,
trackingData: {
totalFee: 45,
marketPrice: 3000,
receivedAmount: 1405,
realizedPnl: 150,
metamaskFeeRate: 0,
metamaskFee: 0,
feeDiscountPercentage: undefined,
estimatedPoints: undefined,
inputMethod: 'default',
},
marketPrice: '3000.00',
// Slippage parameters added in USD-as-source-of-truth refactor
// For full closes (100%), usdAmount is undefined to bypass $10 minimum validation
slippage: {
usdAmount: undefined, // undefined for full close to bypass $10 minimum validation
priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders
maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps,
},
});
expect(handleClosePosition).toHaveBeenCalledWith(
expect.objectContaining({
position: defaultPerpsPositionMock,
size: '',
orderType: 'market',
limitPrice: undefined,
trackingData: expect.objectContaining({
totalFee: 45,
marketPrice: 3000,
receivedAmount: 1405,
realizedPnl: 150,
metamaskFeeRate: 0,
metamaskFee: 0,
feeDiscountPercentage: undefined,
estimatedPoints: undefined,
inputMethod: 'default',
}),
marketPrice: '3000.00',
// Slippage parameters added in USD-as-source-of-truth refactor
// For full closes (100%), usdAmount is undefined to bypass $10 minimum validation
slippage: {
usdAmount: undefined, // undefined for full close to bypass $10 minimum validation
priceAtCalculation: 3000, // effectivePrice: currentPrice for market orders
maxSlippageBps: ORDER_SLIPPAGE_CONFIG.DefaultMarketSlippageBps,
},
}),
);
});
});

Expand Down Expand Up @@ -3049,6 +3057,163 @@ describe('PerpsClosePositionView', () => {
});
});

describe('abandon order tracking', () => {
const abandonInteraction = [
MetaMetricsEvents.PERPS_UI_INTERACTION,
expect.objectContaining({
[PERPS_EVENT_PROPERTY.ACTION]: PERPS_EVENT_VALUE.ACTION.ABANDON_ORDER,
}),
];

let focusSpy: jest.SpyInstance;

const setupAbandonNav = (routes: { key: string }[]) => {
const listeners: Record<string, (() => 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(<PerpsClosePositionView />);

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(<PerpsClosePositionView />);

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(<PerpsClosePositionView />);

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(<PerpsClosePositionView />);

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<string, unknown>) =>
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(<PerpsClosePositionView />);

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(<PerpsClosePositionView />);

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(<PerpsClosePositionView />);

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')
Expand Down
Loading
Loading