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( {