Skip to content

Commit a591a53

Browse files
grvgoel81tommasini
andauthored
feat: persist deeplink attribution with MMKV (#29542)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** Mobile only kept deep-link UTM / attribution_id in memory (via AppStateEventListener / processAttribution). After the first foreground pass the deeplink is often cleared, and onboarding → Wallet Setup Completed usually happens in a later session, so acquisition data was lost and could not be attached to conversion analytics. This PR adds a dedicated Redux slice for attribution with redux-persist + MMKV (separate from root filesystem persist). It exposes saveAttribution, clearAttribution, and expireAttributionIfStale (default 7-day TTL). Data is written when: * AppStateEventListener.processAppStateChange runs after processAttribution() (still gated by dataCollectionForMarketing inside processAttribution), and * handleDeeplink sees a URI with acquisition params while marketing consent is on (covers cold open / session split). Privacy: No persistence when marketing consent is off; persisted data is cleared when the user opts out of marketing data collection and when onboarding is cleared (CLEAR_ONBOARDING). Root persist blacklists attribution so it is not double-stored with the nested MMKV persist. Includes unit tests for the slice (save / clear / expire), marketing sagas, deeplink handler, app state listener, and persistConfig blacklist expectations Jira: https://consensyssoftware.atlassian.net/browse/TO-717 <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Persist deep-link attribution (marketing consent on) Scenario: User opens app from acquisition deeplink and survives process restart Given marketing data collection for analytics is enabled And the app handles a deeplink URL that includes utm_source (and optionally attribution_id/other utm_* params) When the user fully terminates the app and launches MetaMask again without using the same deeplink Then persisted Redux state under the attribution slice still contains the saved acquisition fields (and capturedAt) until TTL or opt-out Scenario: User disables marketing consent after attribution was saved Given marketing data collection for analytics was enabled And attribution had been persisted from a prior deeplink or foreground attribution capture When the user turns off marketing data collection for analytics in settings Then persisted attribution is cleared (no acquisition payload remains in the attribution slice) ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="801" height="72" alt="Screenshot 2026-05-04 at 2 02 56 PM" src="https://github.com/user-attachments/assets/7173f866-6b71-468f-868b-c5964d12cfe4" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new persisted attribution state and wires it into deeplink/app-state flows plus migrations; mistakes could incorrectly retain or clear marketing acquisition data and impact analytics/privacy expectations. > > **Overview** > Adds a new persisted `attribution` Redux slice to store acquisition fields (`utm_*`, `attribution_id`) with a 7-day TTL, deduping, and explicit `saveAttribution`/`clearAttribution`/`expireAttributionIfStale` actions. > > Attribution is now saved from both the legacy deeplink entrypoint (when `security.dataCollectionForMarketing` is `true`) and from `AppStateEventListener` after `processAttribution`, and the listener now clears `currentDeeplink` after processing to avoid re-saving on subsequent resumes. > > On app start, persisted attribution is cleared if marketing consent isn’t explicitly granted, otherwise stale records are expired; new sagas also clear attribution on marketing opt-out and `CLEAR_ONBOARDING`. A migration (`136`) imports any legacy MMKV-stored attribution into the root persisted state and removes the old key, and selectors/tests are added to validate the new behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4e3ac25. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: tommasini <tommasini15@gmail.com>
1 parent 2da5b6b commit a591a53

23 files changed

Lines changed: 1180 additions & 12 deletions

app/core/AppStateEventListener.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { processAttribution } from './processAttribution';
66
import { AnalyticsEventBuilder } from '../util/analytics/AnalyticsEventBuilder';
77
import { analytics } from '../util/analytics/analytics';
88
import ReduxService, { ReduxStore } from './redux';
9+
import { saveAttribution } from './redux/slices/attribution';
10+
11+
function createMockReduxStore(): ReduxStore {
12+
return {
13+
dispatch: jest.fn(),
14+
getState: jest.fn(() => ({})),
15+
} as unknown as ReduxStore;
16+
}
917

1018
jest.mock('./DeeplinkManager/utils/extractURLParams', () => jest.fn());
1119

@@ -86,9 +94,8 @@ describe('AppStateEventListener', () => {
8694
});
8795

8896
it('tracks event when app becomes active and attribution data is available', () => {
89-
jest
90-
.spyOn(ReduxService, 'store', 'get')
91-
.mockReturnValue({} as unknown as ReduxStore);
97+
const mockStore = createMockReduxStore();
98+
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
9299
const mockAttribution = {
93100
attributionId: 'test123',
94101
utm_source: 'source',
@@ -104,6 +111,14 @@ describe('AppStateEventListener', () => {
104111
mockAppStateListener('active');
105112
jest.advanceTimersByTime(2000);
106113

114+
expect(mockStore.dispatch).toHaveBeenCalledWith(
115+
saveAttribution({
116+
attribution_id: 'test123',
117+
utm_source: 'source',
118+
utm_medium: 'medium',
119+
utm_campaign: 'campaign',
120+
}),
121+
);
107122
expect(AnalyticsEventBuilder.createEventBuilder).toHaveBeenCalledWith(
108123
MetaMetricsEvents.APP_OPENED,
109124
);
@@ -114,12 +129,46 @@ describe('AppStateEventListener', () => {
114129
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
115130
mockEventBuilder.build(),
116131
);
132+
expect(appStateManager.currentDeeplink).toBeNull();
133+
});
134+
135+
it('clears currentDeeplink after processing so a later resume does not re-save attribution', () => {
136+
const mockStore = createMockReduxStore();
137+
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
138+
(processAttribution as jest.Mock)
139+
.mockReturnValueOnce({
140+
attributionId: 'x',
141+
utm_source: 'y',
142+
})
143+
.mockReturnValue(undefined);
144+
145+
appStateManager.setCurrentDeeplink('metamask://x');
146+
mockAppStateListener('background');
147+
mockAppStateListener('active');
148+
jest.advanceTimersByTime(2000);
149+
150+
expect(appStateManager.currentDeeplink).toBeNull();
151+
expect(mockStore.dispatch).toHaveBeenCalledWith(
152+
saveAttribution({
153+
attribution_id: 'x',
154+
utm_source: 'y',
155+
}),
156+
);
157+
158+
(mockStore.dispatch as jest.Mock).mockClear();
159+
mockAppStateListener('background');
160+
mockAppStateListener('active');
161+
jest.advanceTimersByTime(2000);
162+
163+
expect(mockStore.dispatch).not.toHaveBeenCalledWith(
164+
expect.objectContaining({ type: saveAttribution.type }),
165+
);
117166
});
118167

119168
it('tracks event when app becomes active without attribution data', () => {
120169
jest
121170
.spyOn(ReduxService, 'store', 'get')
122-
.mockReturnValue({} as unknown as ReduxStore);
171+
.mockReturnValue(createMockReduxStore());
123172
(processAttribution as jest.Mock).mockReturnValue(undefined);
124173

125174
mockAppStateListener('background');
@@ -164,7 +213,7 @@ describe('AppStateEventListener', () => {
164213
jest.clearAllMocks();
165214
jest
166215
.spyOn(ReduxService, 'store', 'get')
167-
.mockReturnValue({} as unknown as ReduxStore);
216+
.mockReturnValue(createMockReduxStore());
168217
const testError = new Error('Test error');
169218
(processAttribution as jest.Mock).mockImplementation(() => {
170219
throw testError;
@@ -206,7 +255,7 @@ describe('AppStateEventListener', () => {
206255
jest.clearAllMocks();
207256
jest
208257
.spyOn(ReduxService, 'store', 'get')
209-
.mockReturnValue({} as unknown as ReduxStore);
258+
.mockReturnValue(createMockReduxStore());
210259
(processAttribution as jest.Mock).mockReturnValue(undefined);
211260

212261
mockAppStateListener('background');
@@ -225,7 +274,7 @@ describe('AppStateEventListener', () => {
225274
jest.clearAllMocks();
226275
jest
227276
.spyOn(ReduxService, 'store', 'get')
228-
.mockReturnValue({} as unknown as ReduxStore);
277+
.mockReturnValue(createMockReduxStore());
229278
(processAttribution as jest.Mock).mockReturnValue(undefined);
230279

231280
// Simulate iOS background → inactive → active sequence
@@ -244,7 +293,7 @@ describe('AppStateEventListener', () => {
244293
jest.clearAllMocks();
245294
jest
246295
.spyOn(ReduxService, 'store', 'get')
247-
.mockReturnValue({} as unknown as ReduxStore);
296+
.mockReturnValue(createMockReduxStore());
248297
(processAttribution as jest.Mock).mockReturnValue(undefined);
249298

250299
// Simulate iOS system permission dialog: active → inactive → active

app/core/AppStateEventListener.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { MetaMetricsEvents } from './Analytics';
44
import { AnalyticsEventBuilder } from '../util/analytics/AnalyticsEventBuilder';
55
import { analytics } from '../util/analytics/analytics';
66
import { processAttribution } from './processAttribution';
7+
import { saveAttribution } from './redux/slices/attribution';
8+
import { attributionPayloadFromProcessAttribution } from './redux/slices/attributionFromSources';
79
import DevLogger from './SDKConnect/utils/DevLogger';
810
import ReduxService from './redux';
911
import generateDeviceAnalyticsMetaData from '../util/metrics';
@@ -90,6 +92,13 @@ export class AppStateEventListener {
9092
currentDeeplink: this.currentDeeplink,
9193
store: ReduxService.store,
9294
});
95+
if (attribution) {
96+
const persistedPayload =
97+
attributionPayloadFromProcessAttribution(attribution);
98+
if (persistedPayload) {
99+
ReduxService.store.dispatch(saveAttribution(persistedPayload));
100+
}
101+
}
93102
// Note: User identification is handled when settings change individually
94103
// We only track the APP_OPENED event on app state transitions
95104
const appOpenedEventBuilder = AnalyticsEventBuilder.createEventBuilder(
@@ -104,6 +113,9 @@ export class AppStateEventListener {
104113
appOpenedEventBuilder.addProperties(attribution);
105114
}
106115
analytics.trackEvent(appOpenedEventBuilder.build());
116+
// One-shot use for attribution: keeping currentDeeplink causes every
117+
// background→active cycle to re-save and reset capturedAt (TTL).
118+
this.currentDeeplink = null;
107119
} catch (error) {
108120
Logger.error(
109121
error as Error,

app/core/DeeplinkManager/handlers/legacy/__tests__/handleDeeplink.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { handleDeeplink } from '../handleDeeplink';
22
import { checkForDeeplink } from '../../../../../actions/user';
3+
import { saveAttribution } from '../../../../redux/slices/attribution';
34
import ReduxService from '../../../../redux';
45
import Logger from '../../../../../util/Logger';
56
import { AppStateEventProcessor } from '../../../../AppStateEventListener';
@@ -22,6 +23,9 @@ jest.mock('../../../../redux', () => ({
2223
default: {
2324
store: {
2425
dispatch: jest.fn(),
26+
getState: jest.fn(() => ({
27+
security: { dataCollectionForMarketing: true },
28+
})),
2529
},
2630
},
2731
}));
@@ -67,6 +71,7 @@ jest.mock('../../../util/deeplinks/deepLinkAnalytics', () => ({
6771

6872
describe('handleDeeplink', () => {
6973
const mockDispatch = ReduxService.store.dispatch as jest.Mock;
74+
const mockGetState = ReduxService.store.getState as jest.Mock;
7075
const mockCheckForDeeplink = checkForDeeplink as jest.Mock;
7176
const mockLoggerError = Logger.error as jest.Mock;
7277
const mockSetCurrentDeeplink =
@@ -75,10 +80,12 @@ describe('handleDeeplink', () => {
7580
const mockHandleMwpDeeplink = SDKConnectV2.handleMwpDeeplink as jest.Mock;
7681
const mockTrackEvent = analytics.trackEvent as jest.Mock;
7782
const mockDetectAppInstallation = detectAppInstallation as jest.Mock;
78-
7983
beforeEach(() => {
8084
jest.clearAllMocks();
8185
mockIsMwpDeeplink.mockReturnValue(false);
86+
mockGetState.mockReturnValue({
87+
security: { dataCollectionForMarketing: true },
88+
});
8289
});
8390

8491
it('processes valid URI and dispatch checkForDeeplink', () => {
@@ -92,6 +99,36 @@ describe('handleDeeplink', () => {
9299
expect(mockLoggerError).not.toHaveBeenCalled();
93100
});
94101

102+
it('dispatches saveAttribution when marketing consent is on and URI has acquisition params', () => {
103+
const testUri =
104+
'metamask://open?utm_source=email&utm_campaign=spring&attribution_id=abc123';
105+
106+
handleDeeplink({ uri: testUri });
107+
108+
expect(mockDispatch).toHaveBeenCalledWith(
109+
saveAttribution({
110+
utm_source: 'email',
111+
utm_campaign: 'spring',
112+
attribution_id: 'abc123',
113+
}),
114+
);
115+
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CHECK_FOR_DEEPLINK' });
116+
});
117+
118+
it('does not dispatch saveAttribution when marketing consent is off', () => {
119+
mockGetState.mockReturnValue({
120+
security: { dataCollectionForMarketing: false },
121+
});
122+
const testUri = 'metamask://open?utm_source=email';
123+
124+
handleDeeplink({ uri: testUri });
125+
126+
expect(mockDispatch).not.toHaveBeenCalledWith(
127+
expect.objectContaining({ type: saveAttribution.type }),
128+
);
129+
expect(mockDispatch).toHaveBeenCalledWith({ type: 'CHECK_FOR_DEEPLINK' });
130+
});
131+
95132
it('processes valid URI with source and passes source to setCurrentDeeplink', () => {
96133
const testUri = 'metamask://test-deeplink';
97134
const testSource = 'push-notification';

app/core/DeeplinkManager/handlers/legacy/handleDeeplink.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { saveAttribution } from '../../../redux/slices/attribution';
2+
import { attributionPayloadFromDeeplink } from '../../../redux/slices/attributionFromSources';
13
import { checkForDeeplink } from '../../../../actions/user';
24
import Logger from '../../../../util/Logger';
35
import { AppStateEventProcessor } from '../../../AppStateEventListener';
@@ -30,6 +32,15 @@ export function handleDeeplink(opts: { uri?: string; source?: string }) {
3032
try {
3133
if (uri && typeof uri === 'string') {
3234
AppStateEventProcessor.setCurrentDeeplink(uri, source);
35+
if (
36+
ReduxService.store.getState().security.dataCollectionForMarketing ===
37+
true
38+
) {
39+
const payload = attributionPayloadFromDeeplink(uri);
40+
if (payload) {
41+
ReduxService.store.dispatch(saveAttribution(payload));
42+
}
43+
}
3344
dispatch(checkForDeeplink());
3445
}
3546
} catch (e) {

app/core/DeeplinkManager/types/deepLink.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface DeeplinkUrlParams {
2222
originatorInfo?: string;
2323
request?: string;
2424
attributionId?: string;
25+
attribution_id?: string;
2526
utm_source?: string;
2627
utm_medium?: string;
2728
utm_campaign?: string;

app/core/processAttribution.test.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,86 @@ describe('processAttribution', () => {
100100
});
101101
});
102102

103+
it('resolves attribution_id (snake_case) when attributionId (camelCase) is absent', () => {
104+
(store.getState as jest.Mock).mockReturnValue({
105+
security: { dataCollectionForMarketing: true },
106+
});
107+
(extractURLParams as jest.Mock).mockReturnValue({
108+
params: {
109+
attributionId: '',
110+
attribution_id: 'snake-abc',
111+
utm_source: 'twitter',
112+
utm_medium: '',
113+
utm_campaign: '',
114+
utm_term: '',
115+
utm_content: '',
116+
},
117+
});
118+
119+
const result = processAttribution({
120+
currentDeeplink:
121+
'metamask://connect?attribution_id=snake-abc&utm_source=twitter',
122+
store,
123+
});
124+
expect(result).toEqual(
125+
expect.objectContaining({ attributionId: 'snake-abc' }),
126+
);
127+
});
128+
129+
it('prefers camelCase attributionId over snake_case attribution_id', () => {
130+
(store.getState as jest.Mock).mockReturnValue({
131+
security: { dataCollectionForMarketing: true },
132+
});
133+
(extractURLParams as jest.Mock).mockReturnValue({
134+
params: {
135+
attributionId: 'camel-abc',
136+
attribution_id: 'snake-abc',
137+
utm_source: '',
138+
utm_medium: '',
139+
utm_campaign: '',
140+
utm_term: '',
141+
utm_content: '',
142+
},
143+
});
144+
145+
const result = processAttribution({
146+
currentDeeplink:
147+
'metamask://connect?attributionId=camel-abc&attribution_id=snake-abc',
148+
store,
149+
});
150+
expect(result).toEqual(
151+
expect.objectContaining({ attributionId: 'camel-abc' }),
152+
);
153+
});
154+
155+
it('does not populate attributionId when both camelCase and snake_case IDs are blank', () => {
156+
(store.getState as jest.Mock).mockReturnValue({
157+
security: { dataCollectionForMarketing: true },
158+
});
159+
(extractURLParams as jest.Mock).mockReturnValue({
160+
params: {
161+
attributionId: '',
162+
attribution_id: ' ',
163+
utm_source: 'news',
164+
utm_medium: '',
165+
utm_campaign: '',
166+
utm_term: '',
167+
utm_content: '',
168+
},
169+
});
170+
171+
const result = processAttribution({
172+
currentDeeplink: 'metamask://connect',
173+
store,
174+
});
175+
expect(result).toEqual(
176+
expect.objectContaining({
177+
attributionId: undefined,
178+
utm_source: 'news',
179+
}),
180+
);
181+
});
182+
103183
it('handles empty UTM parameters gracefully', () => {
104184
(store.getState as jest.Mock).mockReturnValue({
105185
security: { dataCollectionForMarketing: true },

app/core/processAttribution.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,21 @@ export function processAttribution({
3535
const { params } = extractURLParams(currentDeeplink);
3636
const {
3737
attributionId,
38+
attribution_id,
3839
utm_source,
3940
utm_medium,
4041
utm_campaign,
4142
utm_term,
4243
utm_content,
4344
} = params;
4445

46+
// Prefer camelCase attributionId; fall back to snake_case attribution_id
47+
// so URLs using either form are handled consistently.
48+
const resolvedAttributionId =
49+
attributionId?.trim() || attribution_id?.trim() || undefined;
50+
4551
return {
46-
attributionId,
52+
attributionId: resolvedAttributionId,
4753
utm_source,
4854
utm_medium,
4955
utm_campaign,

0 commit comments

Comments
 (0)