Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
86faa00
feat: implement wallet-side analytics for SDKConnectV2 / MWP
ffmcgee725 Mar 24, 2026
5af23b6
fix: fully apply analytics consent gate
ffmcgee725 Mar 25, 2026
a714499
address jiexi feedback
ffmcgee725 Mar 25, 2026
5648f95
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Mar 25, 2026
f732379
test: increase test coverage
ffmcgee725 Mar 25, 2026
9515c47
minor change
ffmcgee725 Mar 25, 2026
5e5d57c
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Mar 25, 2026
f7cfbfe
refactor: remove unecessary async behaviour from synchronous function
ffmcgee725 Mar 25, 2026
69da9df
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Mar 25, 2026
96efa49
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Mar 26, 2026
67cfeb6
Revert "minor change"
ffmcgee725 Mar 26, 2026
661cd13
Revert "refactor: remove unecessary async behaviour from synchronous …
ffmcgee725 Mar 26, 2026
ee26b03
test: coverage for trackWalletEvent function call
ffmcgee725 Mar 26, 2026
649399a
Merge branch 'main' into jc/WAPI-1348
adonesky1 Apr 3, 2026
d438073
refactor: remove redundant wallet_connection_user_approved/rejected e…
adonesky1 Apr 2, 2026
e085d8e
refactor: remove redundant wallet_connection_user_approved/rejected V…
ffmcgee725 Apr 7, 2026
1c6e53c
refactor: align MWP event names and properties with Segment schema
adonesky1 Apr 9, 2026
f8c5383
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 14, 2026
265b384
refactor: use the proper MetaMetrics flow rather than the out of band…
ffmcgee725 Apr 14, 2026
b34e59d
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 14, 2026
11068f9
fix: make sure analytics calls are not blocking mwp flows
ffmcgee725 Apr 14, 2026
da0cf7f
fix: make sure failure event tracking is not lost
ffmcgee725 Apr 14, 2026
984ed41
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 14, 2026
9627dd0
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 14, 2026
4ac6ac5
add transport type to mwp calls and fix remote_session_id
ffmcgee725 Apr 15, 2026
10dfdff
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 15, 2026
891879c
refactor: use constant for mwp ref
ffmcgee725 Apr 15, 2026
fecd54c
fix: add missing signature property to MWP Deep Link Used event
adonesky1 Apr 15, 2026
373f7d4
fix: remove unplanned platform property from MWP analytics events
adonesky1 Apr 15, 2026
30c4875
remove unecessary mocks
ffmcgee725 Apr 16, 2026
1c96b2b
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 16, 2026
8b86d4a
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 16, 2026
41d7707
Merge branch 'main' into jc/WAPI-1348
ffmcgee725 Apr 16, 2026
0e612b5
fix: resolve duplicate REMOTE_CONNECTION_REQUEST_RECEIVED after main …
adonesky1 Apr 16, 2026
efb7b89
refactor: remove dead parameter from function trackMwpDeepLinkUsed
ffmcgee725 Apr 17, 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
4 changes: 4 additions & 0 deletions app/core/Analytics/MetaMetrics.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ enum EVENT_NAME {

// Remote connection events (SDK v1 socket relay, MWP, and WalletConnect)
REMOTE_CONNECTION_REQUEST_RECEIVED = 'Remote Connection Request Received',
REMOTE_CONNECTION_REQUEST_FAILED = 'Remote Connection Request Failed',

// SDK v1 legacy RPC events (socket relay + deeplink protocol only)
SDK_LEGACY_RPC_REQUEST_RECEIVED = 'SDK Legacy RPC Request Received',
Expand Down Expand Up @@ -798,6 +799,9 @@ const events = {
REMOTE_CONNECTION_REQUEST_RECEIVED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_REQUEST_RECEIVED,
),
REMOTE_CONNECTION_REQUEST_FAILED: generateOpt(
EVENT_NAME.REMOTE_CONNECTION_REQUEST_FAILED,
),

// SDK v1 legacy RPC events (socket relay + deeplink protocol only)
SDK_LEGACY_RPC_REQUEST_RECEIVED: generateOpt(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { checkForDeeplink } from '../../../../../actions/user';
import ReduxService from '../../../../redux';
import Logger from '../../../../../util/Logger';
import { AppStateEventProcessor } from '../../../../AppStateEventListener';
import SDKConnectV2 from '../../../../SDKConnectV2';
import { analytics } from '../../../../../util/analytics/analytics';
import { AnalyticsEventBuilder } from '../../../../../util/analytics/AnalyticsEventBuilder';
import { MetaMetricsEvents } from '../../../../Analytics/MetaMetrics.events';
import {
DeepLinkRoute,
SignatureStatus,
} from '../../../types/deepLinkAnalytics.types';
import { detectAppInstallation } from '../../../util/deeplinks/deepLinkAnalytics';

jest.mock('../../../../../actions/user', () => ({
checkForDeeplink: jest.fn(() => ({ type: 'CHECK_FOR_DEEPLINK' })),
Expand All @@ -27,15 +36,49 @@ jest.mock('../../../../AppStateEventListener', () => ({
},
}));

jest.mock('../../../../SDKConnectV2', () => ({
__esModule: true,
default: {
isMwpDeeplink: jest.fn(),
handleMwpDeeplink: jest.fn(),
},
}));

jest.mock('../../../../../util/analytics/analytics', () => ({
analytics: {
trackEvent: jest.fn(),
},
}));

const mockBuild = jest.fn().mockReturnValue({ event: 'mocked' });
const mockAddProperties = jest.fn().mockReturnValue({ build: mockBuild });
jest.mock('../../../../../util/analytics/AnalyticsEventBuilder', () => ({
AnalyticsEventBuilder: {
createEventBuilder: jest.fn().mockReturnValue({
addProperties: (...args: unknown[]) => mockAddProperties(...args),
build: (...args: unknown[]) => mockBuild(...args),
}),
},
}));

jest.mock('../../../util/deeplinks/deepLinkAnalytics', () => ({
detectAppInstallation: jest.fn(),
}));

describe('handleDeeplink', () => {
const mockDispatch = ReduxService.store.dispatch as jest.Mock;
const mockCheckForDeeplink = checkForDeeplink as jest.Mock;
const mockLoggerError = Logger.error as jest.Mock;
const mockSetCurrentDeeplink =
AppStateEventProcessor.setCurrentDeeplink as jest.Mock;
const mockIsMwpDeeplink = SDKConnectV2.isMwpDeeplink as unknown as jest.Mock;
const mockHandleMwpDeeplink = SDKConnectV2.handleMwpDeeplink as jest.Mock;
const mockTrackEvent = analytics.trackEvent as jest.Mock;
const mockDetectAppInstallation = detectAppInstallation as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
mockIsMwpDeeplink.mockReturnValue(false);
});

it('processes valid URI and dispatch checkForDeeplink', () => {
Expand Down Expand Up @@ -137,4 +180,90 @@ describe('handleDeeplink', () => {
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockCheckForDeeplink).not.toHaveBeenCalled();
});

describe('MWP deeplink handling', () => {
const mwpUri = 'metamask://mwp/some-compressed-payload';

beforeEach(() => {
mockIsMwpDeeplink.mockReturnValue(true);
});

it('routes MWP deeplinks to SDKConnectV2 and bypasses the standard flow', () => {
mockDetectAppInstallation.mockResolvedValue(true);

handleDeeplink({ uri: mwpUri });

expect(mockIsMwpDeeplink).toHaveBeenCalledWith(mwpUri);
expect(mockHandleMwpDeeplink).toHaveBeenCalledWith(mwpUri);
expect(mockSetCurrentDeeplink).not.toHaveBeenCalled();
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockCheckForDeeplink).not.toHaveBeenCalled();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name combines multiple behaviors with "and"

Low Severity

The test name 'routes MWP deeplinks to SDKConnectV2 and bypasses the standard flow' uses "and" to combine two distinct behaviors (routing to SDKConnectV2, and bypassing the standard saga flow) into a single test description. Per the unit testing guidelines, test names must not combine multiple logical conditions with AND/OR. The test should be split into two separate, focused test cases.

Fix in Cursor Fix in Web

Triggered by project rule: Unit Testing Guidelines

Reviewed by Cursor Bugbot for commit 1c6e53c. Configure here.


it('fires DEEP_LINK_USED with route MMC_MWP and was_app_installed=true', async () => {
mockDetectAppInstallation.mockResolvedValue(true);

handleDeeplink({ uri: mwpUri });

await flushPromises();

expect(mockDetectAppInstallation).toHaveBeenCalled();
expect(AnalyticsEventBuilder.createEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.DEEP_LINK_USED,
);
expect(mockAddProperties).toHaveBeenCalledWith({
route: DeepLinkRoute.MMC_MWP,
signature: SignatureStatus.MISSING,
was_app_installed: true,
});
expect(mockBuild).toHaveBeenCalled();
expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mocked' });
});

it('fires DEEP_LINK_USED with was_app_installed=false for fresh installs', async () => {
mockDetectAppInstallation.mockResolvedValue(false);

handleDeeplink({ uri: mwpUri });

await flushPromises();

expect(mockAddProperties).toHaveBeenCalledWith({
route: DeepLinkRoute.MMC_MWP,
signature: SignatureStatus.MISSING,
was_app_installed: false,
});
expect(mockTrackEvent).toHaveBeenCalledWith({ event: 'mocked' });
});

it('logs error when detectAppInstallation rejects', async () => {
const installError = new Error('install check failed');
mockDetectAppInstallation.mockRejectedValue(installError);

handleDeeplink({ uri: mwpUri });

await flushPromises();

expect(mockTrackEvent).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
installError,
'DeepLinkAnalytics: Failed to track MWP deep link event',
);
});

it('still routes to SDKConnectV2 even if analytics fails', async () => {
mockDetectAppInstallation.mockRejectedValue(new Error('oops'));

handleDeeplink({ uri: mwpUri });

expect(mockHandleMwpDeeplink).toHaveBeenCalledWith(mwpUri);

await flushPromises();

expect(mockHandleMwpDeeplink).toHaveBeenCalledTimes(1);
});
});
});

function flushPromises(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
35 changes: 35 additions & 0 deletions app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@ import Logger from '../../../../util/Logger';
import { AppStateEventProcessor } from '../../../AppStateEventListener';
import ReduxService from '../../../redux';
import SDKConnectV2 from '../../../SDKConnectV2';
import { analytics } from '../../../../util/analytics/analytics';
import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder';
import { MetaMetricsEvents } from '../../../Analytics/MetaMetrics.events';
import {
DeepLinkRoute,
SignatureStatus,
} from '../../types/deepLinkAnalytics.types';
import { detectAppInstallation } from '../../util/deeplinks/deepLinkAnalytics';

export function handleDeeplink(opts: { uri?: string; source?: string }) {
// This is the earliest JS entry point for deeplinks. We must handle SDKConnectV2
// links here immediately to establish the WebSocket connection as fast as possible,
// without waiting for the app to be unlocked or fully onboarded.
if (SDKConnectV2.isMwpDeeplink(opts.uri)) {
trackMwpDeepLinkUsed();
SDKConnectV2.handleMwpDeeplink(opts.uri);
// By returning here, we bypass the standard saga-based deeplink flow below,
// which would otherwise wait for a LOGIN or ONBOARDING_COMPLETED action.
Expand All @@ -27,3 +36,29 @@ export function handleDeeplink(opts: { uri?: string; source?: string }) {
Logger.error(e as Error, `Deeplink: Error parsing deeplink`);
}
}

/**
* Fire DEEP_LINK_USED for MWP deeplinks asynchronously so the WebSocket
* handshake is never blocked by analytics work.
*/
function trackMwpDeepLinkUsed(): void {
detectAppInstallation()
Comment thread
jiexi marked this conversation as resolved.
.then((wasAppInstalled) => {
const event = AnalyticsEventBuilder.createEventBuilder(
MetaMetricsEvents.DEEP_LINK_USED,
)
.addProperties({
route: DeepLinkRoute.MMC_MWP,
signature: SignatureStatus.MISSING,
was_app_installed: wasAppInstalled,
})
.build();
analytics.trackEvent(event);
})
.catch((error) => {
Logger.error(
error as Error,
'DeepLinkAnalytics: Failed to track MWP deep link event',
);
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export enum DeepLinkRoute {
CARD_ONBOARDING = 'card-onboarding',
CARD_HOME = 'card-home',
NFT = 'nft',
MMC_MWP = 'mmc-mwp',
INVALID = 'invalid',
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,14 @@ const extractNftProperties = (
// NFT route doesn't have sensitive parameters to extract
};

const extractMmcMwpProperties = (
_urlParams: UrlParamValues,
_sensitiveProps: Record<string, string>,
): void => {
// MMC MWP deeplinks carry their payload in a compressed `p` param;
// no route-level sensitive properties to extract here.
};

/**
* Extract properties for INVALID route
* No properties to extract, this function is a placeholder
Expand Down Expand Up @@ -458,6 +466,7 @@ const routeExtractors: Record<
[DeepLinkRoute.CARD_ONBOARDING]: extractCardOnboardingProperties,
[DeepLinkRoute.CARD_HOME]: extractCardHomeProperties,
[DeepLinkRoute.NFT]: extractNftProperties,
[DeepLinkRoute.MMC_MWP]: extractMmcMwpProperties,
[DeepLinkRoute.INVALID]: extractInvalidProperties,
};

Expand Down
64 changes: 64 additions & 0 deletions app/core/SDKConnectV2/services/connection-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { Connection } from './connection';
import { ConnectionRequest } from '../types/connection-request';
import { ConnectionInfo } from '../types/connection-info';
import Engine from '../../Engine';
import { analytics } from '../../../util/analytics/analytics';
import { MetaMetricsEvents } from '../../Analytics';
import { TransportType } from '../../../components/hooks/useAnalytics/useAnalytics.types';

jest.mock('../adapters/host-application-adapter');
jest.mock('../store/connection-store');
Expand All @@ -15,6 +18,11 @@ jest.mock('./connection');
jest.mock('react-native');
jest.mock('@sentry/react-native');
jest.mock('../../Permissions');
jest.mock('../../../util/analytics/analytics', () => ({
analytics: {
trackEvent: jest.fn(),
},
}));
jest.mock('../../../store', () => ({
store: {
dispatch: jest.fn(),
Expand All @@ -24,6 +32,8 @@ jest.mock('../../../store', () => ({
},
}));

const mockTrackEvent = analytics.trackEvent as jest.Mock;

// Factory functions for creating mock objects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createMockConnection = (id: string, overrides: any = {}) => ({
Expand Down Expand Up @@ -285,6 +295,17 @@ describe('ConnectionRegistry', () => {
expect(mockStore.get).toHaveBeenCalledWith('mock-conn-id');

expect(mockHostApp.showNotFoundError).not.toHaveBeenCalled();

expect(mockTrackEvent).toHaveBeenCalledTimes(1);
const trackedEvent = mockTrackEvent.mock.calls[0][0];
expect(trackedEvent.name).toBe('Remote Connection Request Received');
expect(trackedEvent.properties).toEqual(
expect.objectContaining({
remote_session_id: 'mock-conn-id',
transport_type: TransportType.MWP,
found_in_store: true,
}),
);
});

describe('when the connection is not found in the store', () => {
Expand All @@ -296,11 +317,24 @@ describe('ConnectionRegistry', () => {
mockStore,
);

const eventName =
MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED.category;
await registry.handleSimpleDeeplink('mock-conn-id');
await jest.advanceTimersByTimeAsync(1000);

expect(mockStore.get).toHaveBeenCalledWith('mock-conn-id');
expect(mockHostApp.showNotFoundError).toHaveBeenCalled();

expect(mockTrackEvent).toHaveBeenCalledTimes(1);
const trackedEvent = mockTrackEvent.mock.calls[0][0];
expect(trackedEvent.name).toBe(eventName);
expect(trackedEvent.properties).toEqual(
expect.objectContaining({
remote_session_id: 'mock-conn-id',
transport_type: TransportType.MWP,
found_in_store: false,
}),
);
});

it('should show error if the keyring is not unlocked but becomes unlocked later', async () => {
Expand Down Expand Up @@ -348,6 +382,9 @@ describe('ConnectionRegistry', () => {
mockStore,
);

const eventName =
MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_RECEIVED.category;

await registry.handleConnectDeeplink(validDeeplink);

// UI loading state is properly managed
Expand Down Expand Up @@ -394,6 +431,19 @@ describe('ConnectionRegistry', () => {
expiresAt: expect.any(Number),
}),
);

// Analytics: "received" event is tracked via MetaMetrics pipeline
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
const trackedEvent = mockTrackEvent.mock.calls[0][0];
expect(trackedEvent.name).toBe(eventName);
expect(trackedEvent.properties).toEqual(
expect.objectContaining({
remote_session_id: mockConnectionRequest.sessionRequest.id,
transport_type: TransportType.MWP,
sdk_version: '2.0.0',
sdk_platform: 'JavaScript',
}),
);
});

it('should handle invalid URL gracefully', async () => {
Expand Down Expand Up @@ -491,6 +541,8 @@ describe('ConnectionRegistry', () => {
mockConnection.connect.mockRejectedValue(connectionError);

const disconnectSpy = jest.spyOn(registry, 'disconnect');
const eventName =
MetaMetricsEvents.REMOTE_CONNECTION_REQUEST_FAILED.category;

await registry.handleConnectDeeplink(validDeeplink);

Expand Down Expand Up @@ -523,6 +575,18 @@ describe('ConnectionRegistry', () => {
}),
);

// Analytics: both "received" and "failed" events are tracked
expect(mockTrackEvent).toHaveBeenCalledTimes(2);
const failedEvent = mockTrackEvent.mock.calls[1][0];
expect(failedEvent.name).toBe(eventName);
expect(failedEvent.properties).toEqual(
expect.objectContaining({
remote_session_id: mockConnectionRequest.sessionRequest.id,
transport_type: TransportType.MWP,
failure_reason: 'Connection failed',
}),
);

disconnectSpy.mockRestore();
});

Expand Down
Loading
Loading