diff --git a/app/components/Base/RemoteImage/index.test.tsx b/app/components/Base/RemoteImage/index.test.tsx
index 0b7cda5a05a..dd7fc4402ee 100644
--- a/app/components/Base/RemoteImage/index.test.tsx
+++ b/app/components/Base/RemoteImage/index.test.tsx
@@ -632,34 +632,42 @@ describe('RemoteImage', () => {
});
it('renders token image with full ratio and dimensions', async () => {
- jest.spyOn(Dimensions, 'get').mockReturnValue({
- width: 400,
- height: 800,
- scale: 1,
- fontScale: 1,
- });
+ jest.useFakeTimers();
+ try {
+ jest.spyOn(Dimensions, 'get').mockReturnValue({
+ width: 400,
+ height: 800,
+ scale: 1,
+ fontScale: 1,
+ });
- const { UNSAFE_getByType } = render(
- ,
- );
+ const { UNSAFE_getByType } = render(
+ ,
+ );
- await act(async () => {
- const image = UNSAFE_getByType(Image);
- image.props.onLoad({
- source: { width: 600, height: 400 },
+ jest.clearAllTimers();
+
+ await act(async () => {
+ const image = UNSAFE_getByType(Image);
+ image.props.onLoad({
+ source: { width: 600, height: 400 },
+ });
});
- });
- await waitFor(() => {
- const image = UNSAFE_getByType(Image);
- expect(image.props.style.width).toBe(368);
- expect(image.props.style.height).toBeCloseTo(245.33, 1);
- });
+ await waitFor(() => {
+ const image = UNSAFE_getByType(Image);
+ expect(image.props.style.width).toBe(368);
+ expect(image.props.style.height).toBeCloseTo(245.33, 1);
+ });
+ } finally {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ }
});
it('renders token image with chainId prop', async () => {
diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx
index 14f270b3fc0..b0d43e3b2e4 100644
--- a/app/components/Views/ChoosePassword/index.tsx
+++ b/app/components/Views/ChoosePassword/index.tsx
@@ -31,7 +31,7 @@ import {
Checkbox,
} from '@metamask/design-system-react-native';
import StorageWrapper from '../../../store/storage-wrapper';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useStore } from 'react-redux';
import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding';
import {
passwordSet as passwordSetAction,
@@ -95,6 +95,8 @@ import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalytics
import generateDeviceAnalyticsMetaData, {
UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData,
} from '../../../util/metrics';
+import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';
+import type { RootState } from '../../../reducers';
interface KeyringState {
type: string;
@@ -125,6 +127,7 @@ const ChoosePassword = () => {
useRoute>();
const dispatch = useDispatch();
+ const store = useStore();
const metrics = useAnalytics();
const [isSelected, setIsSelected] = useState(false);
@@ -489,6 +492,7 @@ const ChoosePassword = () => {
wallet_setup_type: 'new',
new_wallet: true,
account_type: accountType,
+ ...getWalletSetupCompletedAttributionProperties(store.getState()),
});
endTrace({ name: TraceName.OnboardingSRPAccountCreationTime });
} catch (err) {
@@ -505,6 +509,7 @@ const ChoosePassword = () => {
handlePostWalletCreation,
handleWalletCreationError,
metrics,
+ store,
]);
const onPasswordChange = useCallback(
diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/index.js b/app/components/Views/ImportFromSecretRecoveryPhrase/index.js
index 21410d647e3..f88b2f613e5 100644
--- a/app/components/Views/ImportFromSecretRecoveryPhrase/index.js
+++ b/app/components/Views/ImportFromSecretRecoveryPhrase/index.js
@@ -15,7 +15,7 @@ import {
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { connect } from 'react-redux';
+import { connect, useStore } from 'react-redux';
import {
KeyboardAwareScrollView,
KeyboardProvider,
@@ -97,6 +97,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import SrpInputGrid from '../../UI/SrpInputGrid';
import SrpWordSuggestions from '../../UI/SrpWordSuggestions';
+import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';
const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -115,6 +116,8 @@ const ImportFromSecretRecoveryPhrase = ({
}) => {
const { colors, themeAppearance } = useTheme();
const tw = useTailwind();
+ /** @type {import('redux').Store} */
+ const store = useStore();
const confirmPasswordInput = useRef();
@@ -464,6 +467,7 @@ const ImportFromSecretRecoveryPhrase = ({
wallet_setup_type: 'import',
new_wallet: false,
account_type: AccountType.Imported,
+ ...getWalletSetupCompletedAttributionProperties(store.getState()),
});
fetchAccountsWithActivity();
diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx
index 02cb4931f63..f8ece98e7ad 100644
--- a/app/components/Views/Onboarding/index.test.tsx
+++ b/app/components/Views/Onboarding/index.test.tsx
@@ -258,6 +258,13 @@ jest.mock('../../../core/Analytics/MetaMetrics', () => ({
.MetaMetricsEvents,
}));
+jest.mock(
+ '../../../util/analytics/getWalletSetupCompletedAttributionProperties',
+ () => ({
+ getWalletSetupCompletedAttributionProperties: jest.fn().mockReturnValue({}),
+ }),
+);
+
interface EventBuilder {
addProperties: () => EventBuilder;
build: () => Record;
@@ -3056,6 +3063,127 @@ describe('Onboarding', () => {
Platform.OS = 'ios';
});
+
+ describe('Social Login Completed attribution', () => {
+ const mockGetWalletSetupCompletedAttributionProperties = jest.requireMock(
+ '../../../util/analytics/getWalletSetupCompletedAttributionProperties',
+ ).getWalletSetupCompletedAttributionProperties;
+
+ beforeEach(() => {
+ mockSeedlessOnboardingEnabled.mockReturnValue(true);
+ (StorageWrapper.getItem as jest.Mock).mockResolvedValue(null);
+ (Device.isIos as jest.Mock).mockReturnValue(false);
+ (Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1);
+ Platform.OS = 'android';
+ mockGetWalletSetupCompletedAttributionProperties.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ mockNavigate.mockReset();
+ mockSeedlessOnboardingEnabled.mockReset();
+ Platform.OS = 'ios';
+ });
+
+ it('reads attribution properties from store when Social Login Completed fires with persisted attribution', async () => {
+ const mockUtmProperties = {
+ utm_source: 'google_ads',
+ utm_medium: 'cpc',
+ utm_campaign: 'mobile_acquisition',
+ };
+ mockGetWalletSetupCompletedAttributionProperties.mockReturnValue(
+ mockUtmProperties,
+ );
+
+ const mockCreateLoginHandlerLocal = jest.requireMock(
+ '../../../core/OAuthService/OAuthLoginHandlers',
+ ).createLoginHandler;
+ const mockOAuthServiceLocal = jest.requireMock(
+ '../../../core/OAuthService/OAuthService',
+ ).default;
+
+ mockCreateLoginHandlerLocal.mockReturnValue('mockGoogleHandler');
+ mockOAuthServiceLocal.handleOAuthLogin.mockResolvedValue({
+ type: 'success',
+ existingUser: false,
+ accountName: 'test@example.com',
+ });
+
+ const { getByTestId } = renderScreen(
+ Onboarding,
+ { name: 'Onboarding' },
+ { state: mockInitialState },
+ );
+
+ const createWalletButton = getByTestId(
+ OnboardingSelectorIDs.NEW_WALLET_BUTTON,
+ );
+ await act(async () => {
+ fireEvent.press(createWalletButton);
+ });
+
+ const navCall = mockNavigate.mock.calls.find(
+ (call) =>
+ call[0] === Routes.MODAL.ROOT_MODAL_FLOW &&
+ call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET,
+ );
+ const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle;
+
+ await act(async () => {
+ await googleOAuthFunction(true);
+ });
+
+ expect(
+ mockGetWalletSetupCompletedAttributionProperties,
+ ).toHaveBeenCalledWith(mockInitialState);
+ });
+
+ it('reads attribution properties from store when Apple Social Login Completed fires', async () => {
+ mockGetWalletSetupCompletedAttributionProperties.mockReturnValue({});
+
+ const mockCreateLoginHandlerLocal = jest.requireMock(
+ '../../../core/OAuthService/OAuthLoginHandlers',
+ ).createLoginHandler;
+ const mockOAuthServiceLocal = jest.requireMock(
+ '../../../core/OAuthService/OAuthService',
+ ).default;
+
+ mockCreateLoginHandlerLocal.mockReturnValue('mockAppleHandler');
+ mockOAuthServiceLocal.handleOAuthLogin.mockResolvedValue({
+ type: 'success',
+ existingUser: false,
+ accountName: 'test@icloud.com',
+ });
+
+ const { getByTestId } = renderScreen(
+ Onboarding,
+ { name: 'Onboarding' },
+ { state: mockInitialState },
+ );
+
+ const createWalletButton = getByTestId(
+ OnboardingSelectorIDs.NEW_WALLET_BUTTON,
+ );
+ await act(async () => {
+ fireEvent.press(createWalletButton);
+ });
+
+ const navCall = mockNavigate.mock.calls.find(
+ (call) =>
+ call[0] === Routes.MODAL.ROOT_MODAL_FLOW &&
+ call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET,
+ );
+ const appleOAuthFunction = navCall[1].params.onPressContinueWithApple;
+
+ await act(async () => {
+ await appleOAuthFunction(true);
+ });
+
+ expect(
+ mockGetWalletSetupCompletedAttributionProperties,
+ ).toHaveBeenCalledWith(mockInitialState);
+ });
+ });
});
describe('Error Report Sent Notification', () => {
diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx
index 4e41f5f7eaf..996775fd9a0 100644
--- a/app/components/Views/Onboarding/index.tsx
+++ b/app/components/Views/Onboarding/index.tsx
@@ -97,6 +97,7 @@ import {
presentIosGoogleLoginVersionWarningSheet,
} from './OnboardingIosPrompt';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';
import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation';
import OnboardingAnimation from '../../UI/OnboardingAnimation/OnboardingAnimation';
import {
@@ -460,6 +461,7 @@ const Onboarding = () => {
track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, {
account_type: accountType,
+ ...getWalletSetupCompletedAttributionProperties(store.getState()),
});
if (createWallet) {
if (result.existingUser) {
diff --git a/app/core/AppStateEventListener.test.ts b/app/core/AppStateEventListener.test.ts
index 60f55c38dcd..aae73349652 100644
--- a/app/core/AppStateEventListener.test.ts
+++ b/app/core/AppStateEventListener.test.ts
@@ -6,6 +6,18 @@ import { processAttribution } from './processAttribution';
import { AnalyticsEventBuilder } from '../util/analytics/AnalyticsEventBuilder';
import { analytics } from '../util/analytics/analytics';
import ReduxService, { ReduxStore } from './redux';
+import { saveAttribution } from './redux/slices/attribution';
+
+function createMockReduxStore(
+ dataCollectionForMarketing: boolean | null = true,
+): ReduxStore {
+ return {
+ dispatch: jest.fn(),
+ getState: jest.fn(() => ({
+ security: { dataCollectionForMarketing },
+ })),
+ } as unknown as ReduxStore;
+}
jest.mock('./DeeplinkManager/utils/extractURLParams', () => jest.fn());
@@ -86,9 +98,8 @@ describe('AppStateEventListener', () => {
});
it('tracks event when app becomes active and attribution data is available', () => {
- jest
- .spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ const mockStore = createMockReduxStore();
+ jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
const mockAttribution = {
attributionId: 'test123',
utm_source: 'source',
@@ -104,6 +115,14 @@ describe('AppStateEventListener', () => {
mockAppStateListener('active');
jest.advanceTimersByTime(2000);
+ expect(mockStore.dispatch).toHaveBeenCalledWith(
+ saveAttribution({
+ attribution_id: 'test123',
+ utm_source: 'source',
+ utm_medium: 'medium',
+ utm_campaign: 'campaign',
+ }),
+ );
expect(AnalyticsEventBuilder.createEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
);
@@ -114,12 +133,64 @@ describe('AppStateEventListener', () => {
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
mockEventBuilder.build(),
);
+ expect(appStateManager.currentDeeplink).toBeNull();
+ });
+
+ it('does not persist attribution when marketing consent is not opted in', () => {
+ const mockStore = createMockReduxStore(false);
+ jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
+ (processAttribution as jest.Mock).mockReturnValue({
+ attributionId: 'test123',
+ utm_source: 'source',
+ });
+
+ appStateManager.setCurrentDeeplink('metamask://connect');
+ mockAppStateListener('background');
+ mockAppStateListener('active');
+ jest.advanceTimersByTime(2000);
+
+ expect(mockStore.dispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: saveAttribution.type }),
+ );
+ });
+
+ it('clears currentDeeplink after processing so a later resume does not re-save attribution', () => {
+ const mockStore = createMockReduxStore();
+ jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
+ (processAttribution as jest.Mock)
+ .mockReturnValueOnce({
+ attributionId: 'x',
+ utm_source: 'y',
+ })
+ .mockReturnValue(undefined);
+
+ appStateManager.setCurrentDeeplink('metamask://x');
+ mockAppStateListener('background');
+ mockAppStateListener('active');
+ jest.advanceTimersByTime(2000);
+
+ expect(appStateManager.currentDeeplink).toBeNull();
+ expect(mockStore.dispatch).toHaveBeenCalledWith(
+ saveAttribution({
+ attribution_id: 'x',
+ utm_source: 'y',
+ }),
+ );
+
+ (mockStore.dispatch as jest.Mock).mockClear();
+ mockAppStateListener('background');
+ mockAppStateListener('active');
+ jest.advanceTimersByTime(2000);
+
+ expect(mockStore.dispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: saveAttribution.type }),
+ );
});
it('tracks event when app becomes active without attribution data', () => {
jest
.spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ .mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);
mockAppStateListener('background');
@@ -164,7 +235,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ .mockReturnValue(createMockReduxStore());
const testError = new Error('Test error');
(processAttribution as jest.Mock).mockImplementation(() => {
throw testError;
@@ -206,7 +277,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ .mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);
mockAppStateListener('background');
@@ -225,7 +296,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ .mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);
// Simulate iOS background → inactive → active sequence
@@ -244,7 +315,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
- .mockReturnValue({} as unknown as ReduxStore);
+ .mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);
// Simulate iOS system permission dialog: active → inactive → active
diff --git a/app/core/AppStateEventListener.ts b/app/core/AppStateEventListener.ts
index e857b305ec1..dd21322c8f3 100644
--- a/app/core/AppStateEventListener.ts
+++ b/app/core/AppStateEventListener.ts
@@ -4,6 +4,8 @@ import { MetaMetricsEvents } from './Analytics';
import { AnalyticsEventBuilder } from '../util/analytics/AnalyticsEventBuilder';
import { analytics } from '../util/analytics/analytics';
import { processAttribution } from './processAttribution';
+import { saveAttribution } from './redux/slices/attribution';
+import { attributionPayloadFromProcessAttribution } from './redux/slices/attributionFromSources';
import DevLogger from './SDKConnect/utils/DevLogger';
import ReduxService from './redux';
import generateDeviceAnalyticsMetaData from '../util/metrics';
@@ -90,6 +92,18 @@ export class AppStateEventListener {
currentDeeplink: this.currentDeeplink,
store: ReduxService.store,
});
+ if (attribution) {
+ const persistedPayload =
+ attributionPayloadFromProcessAttribution(attribution);
+ if (persistedPayload) {
+ if (
+ ReduxService.store.getState().security
+ .dataCollectionForMarketing === true
+ ) {
+ ReduxService.store.dispatch(saveAttribution(persistedPayload));
+ }
+ }
+ }
// Note: User identification is handled when settings change individually
// We only track the APP_OPENED event on app state transitions
const appOpenedEventBuilder = AnalyticsEventBuilder.createEventBuilder(
@@ -104,6 +118,9 @@ export class AppStateEventListener {
appOpenedEventBuilder.addProperties(attribution);
}
analytics.trackEvent(appOpenedEventBuilder.build());
+ // One-shot use for attribution: keeping currentDeeplink causes every
+ // background→active cycle to re-save and reset capturedAt (TTL).
+ this.currentDeeplink = null;
} catch (error) {
Logger.error(
error as Error,
diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts
index ddf22bddd1c..0bcbf05a31a 100644
--- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts
@@ -1,5 +1,6 @@
import { handleDeeplink } from '../handleDeeplink';
import { checkForDeeplink } from '../../../../../actions/user';
+import { saveAttribution } from '../../../../redux/slices/attribution';
import ReduxService from '../../../../redux';
import Logger from '../../../../../util/Logger';
import { AppStateEventProcessor } from '../../../../AppStateEventListener';
@@ -22,6 +23,9 @@ jest.mock('../../../../redux', () => ({
default: {
store: {
dispatch: jest.fn(),
+ getState: jest.fn(() => ({
+ security: { dataCollectionForMarketing: true },
+ })),
},
},
}));
@@ -67,6 +71,7 @@ jest.mock('../../../util/deeplinks/deepLinkAnalytics', () => ({
describe('handleDeeplink', () => {
const mockDispatch = ReduxService.store.dispatch as jest.Mock;
+ const mockGetState = ReduxService.store.getState as jest.Mock;
const mockCheckForDeeplink = checkForDeeplink as jest.Mock;
const mockLoggerError = Logger.error as jest.Mock;
const mockSetCurrentDeeplink =
@@ -75,10 +80,12 @@ describe('handleDeeplink', () => {
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);
+ mockGetState.mockReturnValue({
+ security: { dataCollectionForMarketing: true },
+ });
});
it('processes valid URI and dispatch checkForDeeplink', () => {
@@ -92,6 +99,36 @@ describe('handleDeeplink', () => {
expect(mockLoggerError).not.toHaveBeenCalled();
});
+ it('dispatches saveAttribution when marketing consent is on and URI has acquisition params', () => {
+ const testUri =
+ 'metamask://open?utm_source=email&utm_campaign=spring&attribution_id=abc123';
+
+ handleDeeplink({ uri: testUri });
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ saveAttribution({
+ utm_source: 'email',
+ utm_campaign: 'spring',
+ attribution_id: 'abc123',
+ }),
+ );
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'CHECK_FOR_DEEPLINK' });
+ });
+
+ it('does not dispatch saveAttribution when marketing consent is off', () => {
+ mockGetState.mockReturnValue({
+ security: { dataCollectionForMarketing: false },
+ });
+ const testUri = 'metamask://open?utm_source=email';
+
+ handleDeeplink({ uri: testUri });
+
+ expect(mockDispatch).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: saveAttribution.type }),
+ );
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'CHECK_FOR_DEEPLINK' });
+ });
+
it('processes valid URI with source and passes source to setCurrentDeeplink', () => {
const testUri = 'metamask://test-deeplink';
const testSource = 'push-notification';
diff --git a/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts b/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts
index a3c054adfc1..77a073606cc 100644
--- a/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts
@@ -1,3 +1,5 @@
+import { saveAttribution } from '../../../redux/slices/attribution';
+import { attributionPayloadFromDeeplink } from '../../../redux/slices/attributionFromSources';
import { checkForDeeplink } from '../../../../actions/user';
import Logger from '../../../../util/Logger';
import { AppStateEventProcessor } from '../../../AppStateEventListener';
@@ -30,6 +32,15 @@ export function handleDeeplink(opts: { uri?: string; source?: string }) {
try {
if (uri && typeof uri === 'string') {
AppStateEventProcessor.setCurrentDeeplink(uri, source);
+ if (
+ ReduxService.store.getState().security.dataCollectionForMarketing ===
+ true
+ ) {
+ const payload = attributionPayloadFromDeeplink(uri);
+ if (payload) {
+ ReduxService.store.dispatch(saveAttribution(payload));
+ }
+ }
dispatch(checkForDeeplink());
}
} catch (e) {
diff --git a/app/core/DeeplinkManager/types/deepLink.types.ts b/app/core/DeeplinkManager/types/deepLink.types.ts
index b799f122894..c961d84baaa 100644
--- a/app/core/DeeplinkManager/types/deepLink.types.ts
+++ b/app/core/DeeplinkManager/types/deepLink.types.ts
@@ -22,6 +22,7 @@ export interface DeeplinkUrlParams {
originatorInfo?: string;
request?: string;
attributionId?: string;
+ attribution_id?: string;
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
diff --git a/app/core/processAttribution.test.tsx b/app/core/processAttribution.test.tsx
index 5ee68135ab4..2a30bef53dc 100644
--- a/app/core/processAttribution.test.tsx
+++ b/app/core/processAttribution.test.tsx
@@ -128,4 +128,59 @@ describe('processAttribution', () => {
utm_content: '',
});
});
+
+ it('maps attribution_id (snake_case) to attributionId like deeplink save path', () => {
+ (store.getState as jest.Mock).mockReturnValue({
+ security: { dataCollectionForMarketing: true },
+ });
+ (extractURLParams as jest.Mock).mockReturnValue({
+ params: {
+ attributionId: '',
+ attribution_id: 'snake-value',
+ utm_source: 'src',
+ utm_medium: 'med',
+ utm_campaign: '',
+ utm_term: '',
+ utm_content: '',
+ },
+ });
+
+ const result = processAttribution({
+ currentDeeplink:
+ 'metamask://connect?attribution_id=snake-value&utm_source=src&utm_medium=med',
+ store,
+ });
+ expect(result).toEqual({
+ attributionId: 'snake-value',
+ utm_source: 'src',
+ utm_medium: 'med',
+ utm_campaign: '',
+ utm_term: '',
+ utm_content: '',
+ });
+ });
+
+ it('prefers attributionId over attribution_id when both are present', () => {
+ (store.getState as jest.Mock).mockReturnValue({
+ security: { dataCollectionForMarketing: true },
+ });
+ (extractURLParams as jest.Mock).mockReturnValue({
+ params: {
+ attributionId: 'camel',
+ attribution_id: 'snake',
+ utm_source: '',
+ utm_medium: '',
+ utm_campaign: '',
+ utm_term: '',
+ utm_content: '',
+ },
+ });
+
+ const result = processAttribution({
+ currentDeeplink:
+ 'metamask://connect?attributionId=camel&attribution_id=snake',
+ store,
+ });
+ expect(result?.attributionId).toBe('camel');
+ });
});
diff --git a/app/core/processAttribution.tsx b/app/core/processAttribution.tsx
index 9d2dcef76ab..316f0384c3d 100644
--- a/app/core/processAttribution.tsx
+++ b/app/core/processAttribution.tsx
@@ -1,4 +1,5 @@
import extractURLParams from './DeeplinkManager/utils/extractURLParams';
+import { attributionIdFromUrlParams } from './redux/slices/attributionFromSources';
import { RootState } from '../reducers';
import { Store } from 'redux';
import Logger from '../util/Logger';
@@ -33,14 +34,9 @@ export function processAttribution({
if (currentDeeplink) {
const { params } = extractURLParams(currentDeeplink);
- const {
- attributionId,
- utm_source,
- utm_medium,
- utm_campaign,
- utm_term,
- utm_content,
- } = params;
+ const attributionId = attributionIdFromUrlParams(params);
+ const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =
+ params;
return {
attributionId,
diff --git a/app/core/redux/slices/attribution.test.ts b/app/core/redux/slices/attribution.test.ts
new file mode 100644
index 00000000000..ae91d4da666
--- /dev/null
+++ b/app/core/redux/slices/attribution.test.ts
@@ -0,0 +1,167 @@
+import reducer, {
+ type AttributionState,
+ ATTRIBUTION_DEFAULT_TTL_MS,
+ saveAttribution,
+ clearAttribution,
+ expireAttributionIfStale,
+} from './attribution';
+
+const emptyState: AttributionState = { attribution: null };
+
+describe('attribution slice', () => {
+ let dateNowSpy: jest.SpyInstance;
+
+ beforeAll(() => {
+ dateNowSpy = jest.spyOn(Date, 'now');
+ });
+
+ afterAll(() => {
+ dateNowSpy.mockRestore();
+ });
+
+ describe('saveAttribution', () => {
+ it('writes acquisition fields and capturedAt from Date.now()', () => {
+ dateNowSpy.mockReturnValue(5_000);
+
+ const next = reducer(
+ emptyState,
+ saveAttribution({
+ utm_source: 'email',
+ utm_campaign: 'spring',
+ attribution_id: 'aid-1',
+ }),
+ );
+
+ expect(next.attribution).toEqual({
+ utm_source: 'email',
+ utm_campaign: 'spring',
+ attribution_id: 'aid-1',
+ capturedAt: 5_000,
+ });
+ });
+
+ it('overwrites an existing attribution record on a later save', () => {
+ dateNowSpy.mockReturnValue(100);
+ const withFirst = reducer(
+ emptyState,
+ saveAttribution({ utm_source: 'first' }),
+ );
+
+ dateNowSpy.mockReturnValue(200);
+ const next = reducer(
+ withFirst,
+ saveAttribution({ utm_source: 'second', utm_medium: 'cpc' }),
+ );
+
+ expect(next.attribution).toEqual({
+ utm_source: 'second',
+ utm_medium: 'cpc',
+ capturedAt: 200,
+ });
+ });
+
+ it('leaves state unchanged when every payload field is empty after trim', () => {
+ const state = reducer(
+ emptyState,
+ saveAttribution({
+ utm_source: ' ',
+ utm_medium: '',
+ attribution_id: undefined,
+ }),
+ );
+
+ expect(state).toEqual(emptyState);
+ });
+
+ it('persists when only attribution_id is present', () => {
+ dateNowSpy.mockReturnValue(9);
+
+ const next = reducer(
+ emptyState,
+ saveAttribution({ attribution_id: 'only-id' }),
+ );
+
+ expect(next.attribution).toEqual({
+ attribution_id: 'only-id',
+ capturedAt: 9,
+ });
+ });
+
+ it('keeps capturedAt when save payload matches existing acquisition fields', () => {
+ dateNowSpy.mockReturnValue(1_000);
+ const withFirst = reducer(
+ emptyState,
+ saveAttribution({
+ utm_source: 'email',
+ utm_campaign: 'spring',
+ }),
+ );
+
+ dateNowSpy.mockReturnValue(9_000);
+ const next = reducer(
+ withFirst,
+ saveAttribution({
+ utm_source: 'email',
+ utm_campaign: 'spring',
+ }),
+ );
+
+ expect(next.attribution?.capturedAt).toBe(1_000);
+ });
+ });
+
+ describe('clearAttribution', () => {
+ it('sets attribution back to null after a prior save', () => {
+ dateNowSpy.mockReturnValue(1);
+ const withData = reducer(
+ emptyState,
+ saveAttribution({ utm_source: 'x' }),
+ );
+
+ const next = reducer(withData, clearAttribution());
+
+ expect(next).toEqual(emptyState);
+ });
+ });
+
+ describe('expireAttributionIfStale', () => {
+ it('no-ops when attribution is already null', () => {
+ dateNowSpy.mockReturnValue(999_999);
+
+ const next = reducer(emptyState, expireAttributionIfStale());
+
+ expect(next).toEqual(emptyState);
+ });
+
+ it('keeps attribution when age is exactly the TTL', () => {
+ const capturedAt = 10_000;
+ const record = {
+ utm_source: 'keep',
+ capturedAt,
+ };
+
+ dateNowSpy.mockReturnValue(capturedAt + ATTRIBUTION_DEFAULT_TTL_MS);
+
+ const next = reducer({ attribution: record }, expireAttributionIfStale());
+
+ expect(next.attribution).toEqual(record);
+ });
+
+ it('drops attribution when age is greater than the TTL', () => {
+ const capturedAt = 50;
+ dateNowSpy.mockReturnValue(capturedAt + ATTRIBUTION_DEFAULT_TTL_MS + 1);
+
+ const next = reducer(
+ {
+ attribution: {
+ utm_source: 'gone',
+ capturedAt,
+ },
+ },
+ expireAttributionIfStale(),
+ );
+
+ expect(next).toEqual(emptyState);
+ });
+ });
+});
diff --git a/app/core/redux/slices/attribution.ts b/app/core/redux/slices/attribution.ts
new file mode 100644
index 00000000000..ee3856d37c9
--- /dev/null
+++ b/app/core/redux/slices/attribution.ts
@@ -0,0 +1,100 @@
+import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
+
+/**
+ * Persisted acquisition fields. Aligns with Segment / deep-link-used naming.
+ */
+export interface AttributionRecord {
+ utm_source?: string;
+ utm_medium?: string;
+ utm_campaign?: string;
+ utm_term?: string;
+ utm_content?: string;
+ attribution_id?: string;
+ capturedAt: number;
+}
+
+export interface AttributionState {
+ attribution: AttributionRecord | null;
+}
+
+/** Default TTL for attribution records. */
+export const ATTRIBUTION_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+
+const initialState: AttributionState = {
+ attribution: null,
+};
+
+/**
+ * Fields accepted by {@link saveAttribution} (no timestamp — set at save time).
+ */
+export type SaveAttributionPayload = Omit;
+
+function normalizedField(s: string | undefined): string {
+ return (s?.trim() ?? '') || '';
+}
+
+function savePayloadMatchesExistingRecord(
+ record: AttributionRecord,
+ p: SaveAttributionPayload,
+): boolean {
+ return (
+ normalizedField(record.attribution_id) ===
+ normalizedField(p.attribution_id) &&
+ normalizedField(record.utm_source) === normalizedField(p.utm_source) &&
+ normalizedField(record.utm_medium) === normalizedField(p.utm_medium) &&
+ normalizedField(record.utm_campaign) === normalizedField(p.utm_campaign) &&
+ normalizedField(record.utm_term) === normalizedField(p.utm_term) &&
+ normalizedField(record.utm_content) === normalizedField(p.utm_content)
+ );
+}
+
+const attributionSlice = createSlice({
+ name: 'attribution',
+ initialState,
+ reducers: {
+ saveAttribution: (
+ state,
+ action: PayloadAction,
+ ): void => {
+ const p = action.payload;
+ const hasAny =
+ (p.attribution_id?.trim() ?? '') !== '' ||
+ (p.utm_source?.trim() ?? '') !== '' ||
+ (p.utm_medium?.trim() ?? '') !== '' ||
+ (p.utm_campaign?.trim() ?? '') !== '' ||
+ (p.utm_term?.trim() ?? '') !== '' ||
+ (p.utm_content?.trim() ?? '') !== '';
+ if (!hasAny) {
+ return;
+ }
+ if (
+ state.attribution !== null &&
+ savePayloadMatchesExistingRecord(state.attribution, p)
+ ) {
+ return;
+ }
+ state.attribution = {
+ ...p,
+ capturedAt: Date.now(),
+ };
+ },
+ clearAttribution: (state): void => {
+ state.attribution = null;
+ },
+ /** Drop attribution older than {@link ATTRIBUTION_DEFAULT_TTL_MS} (e.g. after rehydrate). */
+ expireAttributionIfStale: (state): void => {
+ const a = state.attribution;
+ if (
+ a !== null &&
+ Date.now() - a.capturedAt > ATTRIBUTION_DEFAULT_TTL_MS
+ ) {
+ state.attribution = null;
+ }
+ },
+ },
+});
+
+export const { saveAttribution, clearAttribution, expireAttributionIfStale } =
+ attributionSlice.actions;
+
+export default attributionSlice.reducer;
diff --git a/app/core/redux/slices/attributionFromSources.ts b/app/core/redux/slices/attributionFromSources.ts
new file mode 100644
index 00000000000..cf11772ef22
--- /dev/null
+++ b/app/core/redux/slices/attributionFromSources.ts
@@ -0,0 +1,122 @@
+import extractURLParams from '../../DeeplinkManager/utils/extractURLParams';
+import type { SaveAttributionPayload } from './attribution';
+
+function readStringField(
+ raw: Record,
+ key: string,
+): string | undefined {
+ const v = raw[key];
+ if (typeof v === 'string' && v.trim() !== '') {
+ return v.trim();
+ }
+ return undefined;
+}
+
+/**
+ * Prefer camelCase when both are present, matching {@link attributionPayloadFromDeeplink}.
+ */
+export function attributionIdFromUrlParams(
+ params: unknown,
+): string | undefined {
+ const raw = params as Record;
+ return (
+ readStringField(raw, 'attributionId') ??
+ readStringField(raw, 'attribution_id')
+ );
+}
+
+/**
+ * Parse acquisition params from a deeplink URL for {@link saveAttribution}.
+ * Returns null if there is nothing attributable.
+ */
+export function attributionPayloadFromDeeplink(
+ deeplinkUrl: string,
+): SaveAttributionPayload | null {
+ const { params } = extractURLParams(deeplinkUrl);
+ const raw = params as unknown as Record;
+
+ const attribution_id = attributionIdFromUrlParams(raw);
+
+ const payload: SaveAttributionPayload = {};
+
+ if (attribution_id !== undefined) {
+ payload.attribution_id = attribution_id;
+ }
+ const utm_source = readStringField(raw, 'utm_source');
+ const utm_medium = readStringField(raw, 'utm_medium');
+ const utm_campaign = readStringField(raw, 'utm_campaign');
+ const utm_term = readStringField(raw, 'utm_term');
+ const utm_content = readStringField(raw, 'utm_content');
+ if (utm_source !== undefined) {
+ payload.utm_source = utm_source;
+ }
+ if (utm_medium !== undefined) {
+ payload.utm_medium = utm_medium;
+ }
+ if (utm_campaign !== undefined) {
+ payload.utm_campaign = utm_campaign;
+ }
+ if (utm_term !== undefined) {
+ payload.utm_term = utm_term;
+ }
+ if (utm_content !== undefined) {
+ payload.utm_content = utm_content;
+ }
+
+ if (Object.keys(payload).length === 0) {
+ return null;
+ }
+ return payload;
+}
+
+interface ProcessAttributionShape {
+ attributionId?: string;
+ utm_source?: string;
+ utm_medium?: string;
+ utm_campaign?: string;
+ utm_term?: string;
+ utm_content?: string;
+}
+
+/**
+ * Map {@link processAttribution} result to {@link saveAttribution} payload.
+ */
+export function attributionPayloadFromProcessAttribution(
+ attribution: ProcessAttributionShape,
+): SaveAttributionPayload | null {
+ const attribution_id =
+ typeof attribution.attributionId === 'string' &&
+ attribution.attributionId.trim() !== ''
+ ? attribution.attributionId.trim()
+ : undefined;
+
+ const payload: SaveAttributionPayload = {};
+ if (attribution_id !== undefined) {
+ payload.attribution_id = attribution_id;
+ }
+ const utm_source = attribution.utm_source?.trim();
+ const utm_medium = attribution.utm_medium?.trim();
+ const utm_campaign = attribution.utm_campaign?.trim();
+ const utm_term = attribution.utm_term?.trim();
+ const utm_content = attribution.utm_content?.trim();
+ if (utm_source) {
+ payload.utm_source = utm_source;
+ }
+ if (utm_medium) {
+ payload.utm_medium = utm_medium;
+ }
+ if (utm_campaign) {
+ payload.utm_campaign = utm_campaign;
+ }
+ if (utm_term) {
+ payload.utm_term = utm_term;
+ }
+ if (utm_content) {
+ payload.utm_content = utm_content;
+ }
+
+ if (Object.keys(payload).length === 0) {
+ return null;
+ }
+ return payload;
+}
diff --git a/app/reducers/index.ts b/app/reducers/index.ts
index 415b58a207c..8dabfd2a51a 100644
--- a/app/reducers/index.ts
+++ b/app/reducers/index.ts
@@ -19,6 +19,7 @@ import networkOnboardReducer from './networkSelector';
import securityReducer, { SecurityState } from './security';
import accountsReducer, { iAccountEvent as AccountsState } from './accounts';
import { combineReducers, Reducer } from 'redux';
+import { persistReducer } from 'redux-persist';
import experimentalSettingsReducer from './experimentalSettings';
import { EngineState } from '../core/Engine';
import rpcEventReducer from './rpcEvents';
@@ -44,6 +45,8 @@ import sampleCounterReducer from '../features/SampleFeature/reducers/sample-coun
import cardReducer from '../core/redux/slices/card';
import rewardsReducer, { RewardsState } from './rewards';
import { isTest } from '../util/test/utils';
+import attributionReducer from '../core/redux/slices/attribution';
+import attributionPersistConfig from '../store/attributionPersistConfig';
/**
* Infer state from a reducer
@@ -60,6 +63,11 @@ export type StateFromReducer =
? State
: never;
+const persistedAttributionReducer = persistReducer(
+ attributionPersistConfig,
+ attributionReducer,
+);
+
// TODO: Convert all reducers to valid TypeScript Redux reducers, and add them
// to this type. Once that is complete, we can automatically generate this type
// using the `StateFromReducersMapObject` type from redux.
@@ -132,6 +140,7 @@ export interface RootState {
cronjobController: StateFromReducer;
rewards: RewardsState;
networkConnectionBanner: NetworkConnectionBannerState;
+ attribution: StateFromReducer;
}
const baseReducers = {
@@ -185,6 +194,9 @@ if (isTest) {
// TypeScript reducers have invalid actions
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const rootReducer = combineReducers(baseReducers);
+const rootReducer = combineReducers({
+ ...baseReducers,
+ attribution: persistedAttributionReducer,
+});
export default rootReducer;
diff --git a/app/selectors/attribution/index.test.ts b/app/selectors/attribution/index.test.ts
new file mode 100644
index 00000000000..6b851c641ec
--- /dev/null
+++ b/app/selectors/attribution/index.test.ts
@@ -0,0 +1,32 @@
+import type { RootState } from '../../reducers';
+import { selectAttributionRecord } from './index';
+
+describe('attribution selectors', () => {
+ it('returns nested attribution record from root state', () => {
+ const state = {
+ attribution: {
+ attribution: {
+ utm_source: 'email',
+ capturedAt: 42,
+ },
+ _persist: { version: 0, rehydrated: true },
+ },
+ } as unknown as RootState;
+
+ expect(selectAttributionRecord(state)).toEqual({
+ utm_source: 'email',
+ capturedAt: 42,
+ });
+ });
+
+ it('returns null when slice has no attribution record', () => {
+ const state = {
+ attribution: {
+ attribution: null,
+ _persist: { version: 0, rehydrated: true },
+ },
+ } as unknown as RootState;
+
+ expect(selectAttributionRecord(state)).toBeNull();
+ });
+});
diff --git a/app/selectors/attribution/index.ts b/app/selectors/attribution/index.ts
new file mode 100644
index 00000000000..a7c64374f29
--- /dev/null
+++ b/app/selectors/attribution/index.ts
@@ -0,0 +1,9 @@
+import { RootState } from '../../reducers';
+import { createSelector } from 'reselect';
+
+const selectAttributionState = (state: RootState) => state.attribution;
+
+export const selectAttributionRecord = createSelector(
+ selectAttributionState,
+ (s) => s.attribution,
+);
diff --git a/app/store/attributionPersistConfig.ts b/app/store/attributionPersistConfig.ts
new file mode 100644
index 00000000000..d5d6c0f121b
--- /dev/null
+++ b/app/store/attributionPersistConfig.ts
@@ -0,0 +1,10 @@
+import type { PersistConfig } from 'redux-persist';
+import type { AttributionState } from '../core/redux/slices/attribution';
+import { attributionPersistStorage } from './attributionPersistStorage';
+
+const attributionPersistConfig: PersistConfig = {
+ key: 'attribution',
+ storage: attributionPersistStorage,
+};
+
+export default attributionPersistConfig;
diff --git a/app/store/attributionPersistStorage.ts b/app/store/attributionPersistStorage.ts
new file mode 100644
index 00000000000..a407294000d
--- /dev/null
+++ b/app/store/attributionPersistStorage.ts
@@ -0,0 +1,22 @@
+import { MMKV } from 'react-native-mmkv';
+
+const MMKV_ID = 'redux-persist-attribution';
+
+const storage = new MMKV({ id: MMKV_ID });
+
+/**
+ * redux-persist storage adapter backed by MMKV, separate from root
+ * FilesystemStorage persist.
+ */
+export const attributionPersistStorage = {
+ getItem: (key: string): Promise =>
+ Promise.resolve(storage.getString(key) ?? null),
+ setItem: (key: string, value: string): Promise => {
+ storage.set(key, value);
+ return Promise.resolve(true);
+ },
+ removeItem: (key: string): Promise => {
+ storage.delete(key);
+ return Promise.resolve();
+ },
+};
diff --git a/app/store/index.ts b/app/store/index.ts
index 254261d5549..4fc754a78e7 100644
--- a/app/store/index.ts
+++ b/app/store/index.ts
@@ -15,6 +15,7 @@ import { onPersistedDataLoaded } from '../actions/user';
import { setBasicFunctionality } from '../actions/settings';
import Logger from '../util/Logger';
import devToolsEnhancer from 'redux-devtools-expo-dev-plugin';
+import { expireAttributionIfStale } from '../core/redux/slices/attribution';
// TODO: Improve type safety by using real Action types instead of `AnyAction`
const pReducer = persistReducer(
@@ -74,6 +75,8 @@ const createStoreAndPersistor = async () => {
store.dispatch(
setBasicFunctionality(currentState.settings.basicFunctionalityEnabled),
);
+
+ store.dispatch(expireAttributionIfStale());
};
persistor = persistStore(store, null, onPersistComplete);
diff --git a/app/store/persistConfig/index.ts b/app/store/persistConfig/index.ts
index 83a5c2e7a52..fff0f288917 100644
--- a/app/store/persistConfig/index.ts
+++ b/app/store/persistConfig/index.ts
@@ -189,6 +189,7 @@ const persistConfig = {
'engine',
'qrKeyringScanner',
'securityAlerts',
+ 'attribution',
],
storage: MigratedStorage,
transforms: [persistUserTransform, persistOnboardingTransform],
diff --git a/app/store/persistConfig/persistConfig.test.ts b/app/store/persistConfig/persistConfig.test.ts
index 8e140bbb85d..02f8c13a63f 100644
--- a/app/store/persistConfig/persistConfig.test.ts
+++ b/app/store/persistConfig/persistConfig.test.ts
@@ -139,6 +139,7 @@ describe('persistConfig', () => {
'engine',
'qrKeyringScanner',
'securityAlerts',
+ 'attribution',
]);
});
diff --git a/app/store/sagas/index.ts b/app/store/sagas/index.ts
index b7fb1e94cda..b8be4fbdc33 100644
--- a/app/store/sagas/index.ts
+++ b/app/store/sagas/index.ts
@@ -37,6 +37,10 @@ import trackErrorAsAnalytics from '../../util/metrics/TrackError/trackErrorAsAna
import { providerErrors } from '@metamask/rpc-errors';
import { backfillSocialLoginMarketingConsentSaga } from './backfillSocialLoginMarketingConsent';
import { promptIosGoogleWarningSheetSaga } from './onboarding/legacyIosGoogleReminder';
+import {
+ watchMarketingAttributionOnClearOnboarding,
+ watchMarketingAttributionOnConsentChange,
+} from './marketingAttribution';
/**
* Creates a channel to listen to app state changes.
@@ -349,6 +353,9 @@ export function* rootSaga() {
// persisted state has been rehydrated and app services are available.
yield fork(backfillSocialLoginMarketingConsentSaga);
+ yield fork(watchMarketingAttributionOnConsentChange);
+ yield fork(watchMarketingAttributionOnClearOnboarding);
+
yield fork(promptIosGoogleWarningSheetSaga);
///: BEGIN:ONLY_INCLUDE_IF(snaps)
yield fork(handleSnapsRegistry);
diff --git a/app/store/sagas/marketingAttribution.test.ts b/app/store/sagas/marketingAttribution.test.ts
new file mode 100644
index 00000000000..b4df7c977c1
--- /dev/null
+++ b/app/store/sagas/marketingAttribution.test.ts
@@ -0,0 +1,41 @@
+import { expectSaga } from 'redux-saga-test-plan';
+import {
+ ActionType,
+ setDataCollectionForMarketing,
+} from '../../actions/security';
+import { CLEAR_ONBOARDING } from '../../actions/onboarding';
+import { clearAttribution } from '../../core/redux/slices/attribution';
+import {
+ watchMarketingAttributionOnClearOnboarding,
+ watchMarketingAttributionOnConsentChange,
+} from './marketingAttribution';
+
+describe('marketingAttribution sagas', () => {
+ describe('watchMarketingAttributionOnConsentChange', () => {
+ it('dispatches clearAttribution when marketing consent becomes false', async () => {
+ await expectSaga(watchMarketingAttributionOnConsentChange)
+ .put(clearAttribution())
+ .dispatch({
+ type: ActionType.SET_DATA_COLLECTION_FOR_MARKETING,
+ enabled: false,
+ })
+ .silentRun();
+ });
+
+ it('does not dispatch clearAttribution when marketing consent becomes true', async () => {
+ await expectSaga(watchMarketingAttributionOnConsentChange)
+ .not.put(clearAttribution())
+ .dispatch(setDataCollectionForMarketing(true))
+ .silentRun();
+ });
+ });
+
+ describe('watchMarketingAttributionOnClearOnboarding', () => {
+ it('dispatches clearAttribution when onboarding is cleared', async () => {
+ await expectSaga(watchMarketingAttributionOnClearOnboarding)
+ .put(clearAttribution())
+ .dispatch({ type: CLEAR_ONBOARDING })
+ .silentRun();
+ });
+ });
+});
diff --git a/app/store/sagas/marketingAttribution.ts b/app/store/sagas/marketingAttribution.ts
new file mode 100644
index 00000000000..86481724d96
--- /dev/null
+++ b/app/store/sagas/marketingAttribution.ts
@@ -0,0 +1,35 @@
+import { takeEvery, put } from 'redux-saga/effects';
+import {
+ ActionType,
+ type SetDataCollectionForMarketing,
+} from '../../actions/security';
+import { CLEAR_ONBOARDING } from '../../actions/onboarding';
+import { clearAttribution } from '../../core/redux/slices/attribution';
+
+/**
+ * Clear persisted acquisition data when marketing consent is disabled.
+ */
+export function* watchMarketingAttributionOnConsentChange() {
+ yield takeEvery(
+ ActionType.SET_DATA_COLLECTION_FOR_MARKETING,
+ function* setDataCollectionForMarketingHandler({
+ enabled,
+ }: SetDataCollectionForMarketing) {
+ if (enabled === false) {
+ yield put(clearAttribution());
+ }
+ },
+ );
+}
+
+/**
+ * Clear attribution when the onboarding slice is reset (e.g. wallet delete).
+ */
+export function* watchMarketingAttributionOnClearOnboarding() {
+ yield takeEvery(
+ CLEAR_ONBOARDING,
+ function* clearOnboardingMarketingHandler() {
+ yield put(clearAttribution());
+ },
+ );
+}
diff --git a/app/util/analytics/getWalletSetupCompletedAttributionProperties.test.ts b/app/util/analytics/getWalletSetupCompletedAttributionProperties.test.ts
new file mode 100644
index 00000000000..0751a5d6c38
--- /dev/null
+++ b/app/util/analytics/getWalletSetupCompletedAttributionProperties.test.ts
@@ -0,0 +1,148 @@
+import initialRootState from '../test/initial-root-state';
+import {
+ ATTRIBUTION_DEFAULT_TTL_MS,
+ type AttributionRecord,
+} from '../../core/redux/slices/attribution';
+import type { RootState } from '../../reducers';
+import { getWalletSetupCompletedAttributionProperties } from './getWalletSetupCompletedAttributionProperties';
+
+function stateWithAttribution(
+ overrides: Partial & {
+ attributionRecord: AttributionRecord | null;
+ },
+): RootState {
+ const { attributionRecord, ...rest } = overrides;
+ return {
+ ...initialRootState,
+ ...rest,
+ attribution: {
+ ...initialRootState.attribution,
+ attribution: attributionRecord,
+ },
+ } as RootState;
+}
+
+describe('getWalletSetupCompletedAttributionProperties', () => {
+ const baseRecord: AttributionRecord = {
+ utm_source: 'src',
+ utm_medium: 'med',
+ utm_campaign: 'camp',
+ utm_term: 'term',
+ utm_content: 'content',
+ attribution_id: 'id-1',
+ capturedAt: 1_700_000_000_000,
+ };
+
+ it('returns utm fields and attribution_id when consent is true and record is in TTL', () => {
+ const now = baseRecord.capturedAt + 1000;
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: true,
+ },
+ attributionRecord: baseRecord,
+ });
+
+ expect(getWalletSetupCompletedAttributionProperties(state, now)).toEqual({
+ utm_source: 'src',
+ utm_medium: 'med',
+ utm_campaign: 'camp',
+ utm_term: 'term',
+ utm_content: 'content',
+ attribution_id: 'id-1',
+ });
+ });
+
+ it('returns empty object when marketing consent is false', () => {
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: false,
+ },
+ attributionRecord: baseRecord,
+ });
+
+ expect(
+ getWalletSetupCompletedAttributionProperties(
+ state,
+ baseRecord.capturedAt + 1000,
+ ),
+ ).toEqual({});
+ });
+
+ it('returns empty object when marketing consent is unset', () => {
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: null,
+ },
+ attributionRecord: baseRecord,
+ });
+
+ expect(
+ getWalletSetupCompletedAttributionProperties(
+ state,
+ baseRecord.capturedAt + 1000,
+ ),
+ ).toEqual({});
+ });
+
+ it('returns empty object when there is no persisted attribution', () => {
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: true,
+ },
+ attributionRecord: null,
+ });
+
+ expect(
+ getWalletSetupCompletedAttributionProperties(
+ state,
+ baseRecord.capturedAt + 1000,
+ ),
+ ).toEqual({});
+ });
+
+ it('returns empty object when attribution is past TTL', () => {
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: true,
+ },
+ attributionRecord: baseRecord,
+ });
+
+ const now = baseRecord.capturedAt + ATTRIBUTION_DEFAULT_TTL_MS + 1;
+ expect(getWalletSetupCompletedAttributionProperties(state, now)).toEqual(
+ {},
+ );
+ });
+
+ it('omits empty or whitespace-only string fields', () => {
+ const record: AttributionRecord = {
+ utm_source: ' valid ',
+ utm_medium: '',
+ utm_campaign: ' ',
+ attribution_id: 'x',
+ capturedAt: 1_700_000_000_000,
+ };
+ const state = stateWithAttribution({
+ security: {
+ ...initialRootState.security,
+ dataCollectionForMarketing: true,
+ },
+ attributionRecord: record,
+ });
+
+ expect(
+ getWalletSetupCompletedAttributionProperties(
+ state,
+ record.capturedAt + 1,
+ ),
+ ).toEqual({
+ utm_source: 'valid',
+ attribution_id: 'x',
+ });
+ });
+});
diff --git a/app/util/analytics/getWalletSetupCompletedAttributionProperties.ts b/app/util/analytics/getWalletSetupCompletedAttributionProperties.ts
new file mode 100644
index 00000000000..b5d7e03b6f2
--- /dev/null
+++ b/app/util/analytics/getWalletSetupCompletedAttributionProperties.ts
@@ -0,0 +1,51 @@
+import type { JsonMap } from '../../core/Analytics/MetaMetrics.types';
+import {
+ ATTRIBUTION_DEFAULT_TTL_MS,
+ type AttributionRecord,
+} from '../../core/redux/slices/attribution';
+import type { RootState } from '../../reducers';
+import { selectAttributionRecord } from '../../selectors/attribution';
+
+const ATTRIBUTION_PROPERTY_KEYS: (keyof AttributionRecord)[] = [
+ 'utm_source',
+ 'utm_medium',
+ 'utm_campaign',
+ 'utm_term',
+ 'utm_content',
+ 'attribution_id',
+];
+
+/**
+ * Returns acquisition fields to attach to Wallet Setup Completed when persisted
+ * attribution exists, is within TTL, and the user has opted into marketing data collection.
+ */
+export function getWalletSetupCompletedAttributionProperties(
+ state: RootState,
+ nowMs: number = Date.now(),
+): JsonMap {
+ if (state.security.dataCollectionForMarketing !== true) {
+ return {};
+ }
+
+ const record = selectAttributionRecord(state);
+ if (record === null) {
+ return {};
+ }
+
+ if (nowMs - record.capturedAt > ATTRIBUTION_DEFAULT_TTL_MS) {
+ return {};
+ }
+
+ const out: JsonMap = {};
+ for (const key of ATTRIBUTION_PROPERTY_KEYS) {
+ const value = record[key];
+ if (typeof value !== 'string') {
+ continue;
+ }
+ const trimmed = value.trim();
+ if (trimmed !== '') {
+ out[key] = trimmed;
+ }
+ }
+ return out;
+}
diff --git a/app/util/test/initial-root-state.ts b/app/util/test/initial-root-state.ts
index 5d35ff09334..c9aad4c2949 100644
--- a/app/util/test/initial-root-state.ts
+++ b/app/util/test/initial-root-state.ts
@@ -75,6 +75,10 @@ const initialRootState: RootState = {
card: initialCardState,
rewards: initialRewardsState,
networkConnectionBanner: initialNetworkConnectionBannerState,
+ attribution: {
+ attribution: null,
+ _persist: { version: 0, rehydrated: true },
+ },
};
if (isTest) {
diff --git a/tests/framework/fixtures/FixtureBuilder.ts b/tests/framework/fixtures/FixtureBuilder.ts
index 15c4cbc284d..e6043627644 100644
--- a/tests/framework/fixtures/FixtureBuilder.ts
+++ b/tests/framework/fixtures/FixtureBuilder.ts
@@ -817,6 +817,39 @@ class FixtureBuilder {
return this;
}
+ /**
+ * Preloads marketing consent and persisted acquisition data for E2E analytics tests
+ * that assert Wallet Setup Completed includes utm_* and attribution_id.
+ */
+ withPreloadedMarketingAttributionForWalletSetupAnalytics(
+ capturedAt: number = Date.now(),
+ ) {
+ if (!this.fixture.state) {
+ (this.fixture as Fixture).state = {} as Fixture['state'];
+ }
+ merge(this.fixture.state, {
+ security: {
+ allowLoginWithRememberMe: false,
+ dataCollectionForMarketing: true,
+ isNFTAutoDetectionModalViewed: false,
+ osAuthEnabled: true,
+ },
+ attribution: {
+ _persist: { version: -1, rehydrated: true },
+ attribution: {
+ utm_source: 'fixture_utm_source',
+ utm_medium: 'fixture_utm_medium',
+ utm_campaign: 'fixture_utm_campaign',
+ utm_term: 'fixture_utm_term',
+ utm_content: 'fixture_utm_content',
+ attribution_id: 'fixture_attribution_id',
+ capturedAt,
+ },
+ },
+ });
+ return this;
+ }
+
/**
* @deprecated Use withNetworkController instead
* @param chainId
diff --git a/tests/helpers/analytics/expectations/wallet-setup-attribution.analytics.ts b/tests/helpers/analytics/expectations/wallet-setup-attribution.analytics.ts
new file mode 100644
index 00000000000..b31e5a3913e
--- /dev/null
+++ b/tests/helpers/analytics/expectations/wallet-setup-attribution.analytics.ts
@@ -0,0 +1,49 @@
+import type { AnalyticsExpectations } from '../../../framework';
+import { onboardingEvents } from '../helpers';
+import { importWalletWithMetricsOptInExpectations } from './import-wallet.analytics';
+import { newWalletWithMetricsOptInExpectations } from './new-wallet.analytics';
+
+/**
+ * Values must match `withPreloadedMarketingAttributionForWalletSetupAnalytics` in
+ * `tests/framework/fixtures/FixtureBuilder.ts`.
+ */
+export const E2E_WALLET_SETUP_ATTRIBUTION_FIXTURE_PROPS = {
+ utm_source: 'fixture_utm_source',
+ utm_medium: 'fixture_utm_medium',
+ utm_campaign: 'fixture_utm_campaign',
+ utm_term: 'fixture_utm_term',
+ utm_content: 'fixture_utm_content',
+ attribution_id: 'fixture_attribution_id',
+} as const;
+
+export const newWalletWithMetricsOptInAndAttributionExpectations: AnalyticsExpectations =
+ {
+ ...newWalletWithMetricsOptInExpectations,
+ events: newWalletWithMetricsOptInExpectations.events?.map((e) =>
+ e.name === onboardingEvents.WALLET_SETUP_COMPLETED
+ ? {
+ ...e,
+ matchProperties: {
+ ...e.matchProperties,
+ ...E2E_WALLET_SETUP_ATTRIBUTION_FIXTURE_PROPS,
+ },
+ }
+ : e,
+ ),
+ };
+
+export const importWalletWithMetricsOptInAndAttributionExpectations: AnalyticsExpectations =
+ {
+ ...importWalletWithMetricsOptInExpectations,
+ events: importWalletWithMetricsOptInExpectations.events?.map((e) =>
+ e.name === onboardingEvents.WALLET_SETUP_COMPLETED
+ ? {
+ ...e,
+ matchProperties: {
+ ...e.matchProperties,
+ ...E2E_WALLET_SETUP_ATTRIBUTION_FIXTURE_PROPS,
+ },
+ }
+ : e,
+ ),
+ };
diff --git a/tests/smoke/wallet/analytics/import-wallet.spec.ts b/tests/smoke/wallet/analytics/import-wallet.spec.ts
index 646a26dc83a..e438848358b 100644
--- a/tests/smoke/wallet/analytics/import-wallet.spec.ts
+++ b/tests/smoke/wallet/analytics/import-wallet.spec.ts
@@ -23,6 +23,7 @@ import {
importWalletMetricsOptOutExpectations,
importWalletWithMetricsOptInExpectations,
} from '../../../helpers/analytics/expectations/import-wallet.analytics';
+import { importWalletWithMetricsOptInAndAttributionExpectations } from '../../../helpers/analytics/expectations/wallet-setup-attribution.analytics';
import {
IDENTITY_TEAM_PASSWORD,
IDENTITY_TEAM_SEED_PHRASE,
@@ -37,6 +38,64 @@ describe(SmokeWalletPlatform('Analytics during import wallet flow'), () => {
await TestHelpers.reverseServerPort();
});
+ it('includes persisted acquisition params on Wallet Setup Completed when marketing consent and attribution are preloaded', async () => {
+ await withFixtures(
+ {
+ fixture: new FixtureBuilder()
+ .withOnboardingFixture()
+ .withPreloadedMarketingAttributionForWalletSetupAnalytics()
+ .build(),
+ restartDevice: true,
+ testSpecificMock: async (mockServer: Mockttp) => {
+ await setupRemoteFeatureFlagsMock(mockServer, {
+ ...remoteFeatureMultichainAccountsAccountDetails(),
+ ...remoteFeaturePredictGtmOnboardingModalDisabled(),
+ });
+ },
+ analyticsExpectations:
+ importWalletWithMetricsOptInAndAttributionExpectations,
+ },
+ async ({ mockServer }) => {
+ if (!mockServer) {
+ throw new Error(
+ 'Mock server is not defined, check testSpecificMock setup',
+ );
+ }
+
+ const profileAccountsMatcher = {
+ method: 'PUT' as const,
+ urlSubstring: AUTHENTICATION_PROFILE_ACCOUNTS_URL_MARKER,
+ };
+ const profileAccountsBaseline = await countProxiedRequestsMatching(
+ mockServer,
+ profileAccountsMatcher,
+ );
+
+ await importWalletWithRecoveryPhrase({
+ seedPhrase: IDENTITY_TEAM_SEED_PHRASE,
+ password: IDENTITY_TEAM_PASSWORD,
+ optInToMetrics: true,
+ });
+
+ await waitForAdditionalProxiedRequestsMatching(
+ mockServer,
+ profileAccountsMatcher,
+ profileAccountsBaseline,
+ {
+ description:
+ 'New PUT authentication.api.cx.metamask.io/api/v2/profile/accounts observed after wallet import',
+ timeout: PROFILE_ACCOUNTS_PROXIED_REQUEST_TIMEOUT_MS,
+ successLog: {
+ logger,
+ label:
+ 'PUT authentication.api.cx.metamask.io/api/v2/profile/accounts after import',
+ },
+ },
+ );
+ },
+ );
+ });
+
it('tracks analytics events during wallet import flow', async () => {
await withFixtures(
{
diff --git a/tests/smoke/wallet/analytics/new-wallet.spec.ts b/tests/smoke/wallet/analytics/new-wallet.spec.ts
index bd1535db6d2..810cbf3f4f5 100644
--- a/tests/smoke/wallet/analytics/new-wallet.spec.ts
+++ b/tests/smoke/wallet/analytics/new-wallet.spec.ts
@@ -8,6 +8,7 @@ import {
newWalletWithMetricsOptInExpectations,
newWalletMetricsOptOutExpectations,
} from '../../../helpers/analytics/expectations/new-wallet.analytics';
+import { newWalletWithMetricsOptInAndAttributionExpectations } from '../../../helpers/analytics/expectations/wallet-setup-attribution.analytics';
import { remoteFeaturePredictGtmOnboardingModalDisabled } from '../../../api-mocking/mock-responses/feature-flags-mocks';
import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper';
import { Mockttp } from 'mockttp';
@@ -17,6 +18,29 @@ describe(SmokeWalletPlatform('Analytics during new wallet flow'), () => {
await TestHelpers.reverseServerPort();
});
+ it('includes persisted acquisition params on Wallet Setup Completed when marketing consent and attribution are preloaded', async () => {
+ await withFixtures(
+ {
+ fixture: new FixtureBuilder()
+ .withOnboardingFixture()
+ .withPreloadedMarketingAttributionForWalletSetupAnalytics()
+ .build(),
+ restartDevice: true,
+ testSpecificMock: async (mockServer: Mockttp) => {
+ await setupRemoteFeatureFlagsMock(
+ mockServer,
+ remoteFeaturePredictGtmOnboardingModalDisabled(),
+ );
+ },
+ analyticsExpectations:
+ newWalletWithMetricsOptInAndAttributionExpectations,
+ },
+ async () => {
+ await CreateNewWallet();
+ },
+ );
+ });
+
it('tracks analytics events during new wallet flow', async () => {
await withFixtures(
{