wallet-stack 1.0.0-alpha.125 → 1.0.0-alpha.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wallet-stack",
3
- "version": "1.0.0-alpha.125",
3
+ "version": "1.0.0-alpha.127",
4
4
  "author": "Valora Inc",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -92,6 +92,7 @@
92
92
  "expo-camera": "~16.1.11",
93
93
  "expo-splash-screen": "~0.30.10",
94
94
  "lottie-react-native": "^5.1.6",
95
+ "mixpanel-react-native": "^3.2.1",
95
96
  "react": "19.0.0",
96
97
  "react-native": "0.79.6",
97
98
  "react-native-adjust": "^4.38.1",
@@ -160,6 +161,7 @@
160
161
  "is-ip": "^3.1.0",
161
162
  "jwt-decode": "^4.0.0",
162
163
  "lodash": "^4.17.21",
164
+ "mixpanel-react-native": "^3.2.1",
163
165
  "permissionless": "^0.2.57",
164
166
  "react-async-hook": "^4.0.0",
165
167
  "react-i18next": "^15.4.1",
@@ -126,11 +126,15 @@ const SecuritySubmenu = ({ route, navigation }: Props) => {
126
126
  return onPressContinueWithAccountRemoval()
127
127
  }
128
128
 
129
- const handleToggleAnalytics = (value: boolean) => {
130
- dispatch(setAnalyticsEnabled(value))
131
- AppAnalytics.track(SettingsEvents.settings_analytics, {
132
- enabled: value,
133
- })
129
+ const handleToggleAnalytics = (enabled: boolean) => {
130
+ // Fire analytics event either before or after dispatch -- to ensure it is not skipped
131
+ if (enabled) {
132
+ dispatch(setAnalyticsEnabled(enabled))
133
+ AppAnalytics.track(SettingsEvents.settings_analytics, { enabled })
134
+ } else {
135
+ AppAnalytics.track(SettingsEvents.settings_analytics, { enabled })
136
+ dispatch(setAnalyticsEnabled(enabled))
137
+ }
134
138
  }
135
139
 
136
140
  const handleRequirePinToggle = (value: boolean) => {
@@ -21,7 +21,7 @@ import Logger from 'src/utils/Logger'
21
21
  import { ViemKeychainAccount } from 'src/viem/keychainAccountToAccount'
22
22
  import { getKeychainAccounts } from 'src/web3/contracts'
23
23
  import networkConfig from 'src/web3/networkConfig'
24
- import { UnlockResult, getOrCreateAccount, unlockAccount } from 'src/web3/saga'
24
+ import { UnlockResult, createAccount, unlockAccount } from 'src/web3/saga'
25
25
  import { walletAddressSelector } from 'src/web3/selectors'
26
26
  import { initializeAccountSuccess, saveSignedMessage } from './actions'
27
27
 
@@ -140,6 +140,33 @@ describe('initializeAccount', () => {
140
140
  mockFetch.resetMocks()
141
141
  })
142
142
 
143
+ it('should call createAccount when not restoring', async () => {
144
+ await expectSaga(initializeAccountSaga)
145
+ .provide([
146
+ [select(choseToRestoreAccountSelector), false],
147
+ [call(createAccount), undefined],
148
+ [call(generateSignedMessage), undefined],
149
+ ])
150
+ .call(createAccount)
151
+ .put(initializeAccountSuccess())
152
+ .run()
153
+ })
154
+
155
+ it('should skip createAccount when restoring', async () => {
156
+ mockFetch.mockResponse(JSON.stringify({ data: { phoneNumbers: [] } }))
157
+
158
+ await expectSaga(initializeAccountSaga)
159
+ .provide([
160
+ [select(choseToRestoreAccountSelector), true],
161
+ [call(generateSignedMessage), undefined],
162
+ [call(retrieveSignedMessage), 'some signed message'],
163
+ [select(walletAddressSelector), '0xabc'],
164
+ ])
165
+ .not.call.fn(createAccount)
166
+ .put(initializeAccountSuccess())
167
+ .run()
168
+ })
169
+
143
170
  it('should handle the last previously verified phone number', async () => {
144
171
  mockFetch.mockResponse(
145
172
  JSON.stringify({ data: { phoneNumbers: ['+1302123456', '+31619123456'] } })
@@ -147,9 +174,8 @@ describe('initializeAccount', () => {
147
174
 
148
175
  await expectSaga(initializeAccountSaga)
149
176
  .provide([
150
- [call(getOrCreateAccount), undefined],
151
- [call(generateSignedMessage), undefined],
152
177
  [select(choseToRestoreAccountSelector), true],
178
+ [call(generateSignedMessage), undefined],
153
179
  [call(retrieveSignedMessage), 'some signed message'],
154
180
  [select(walletAddressSelector), '0xabc'],
155
181
  ])
@@ -176,9 +202,8 @@ describe('initializeAccount', () => {
176
202
 
177
203
  await expectSaga(initializeAccountSaga)
178
204
  .provide([
179
- [call(getOrCreateAccount), undefined],
180
- [call(generateSignedMessage), undefined],
181
205
  [select(choseToRestoreAccountSelector), true],
206
+ [call(generateSignedMessage), undefined],
182
207
  [call(retrieveSignedMessage), 'some signed message'],
183
208
  [select(walletAddressSelector), '0xabc'],
184
209
  ])
@@ -194,9 +219,8 @@ describe('initializeAccount', () => {
194
219
 
195
220
  await expectSaga(initializeAccountSaga)
196
221
  .provide([
197
- [call(getOrCreateAccount), undefined],
198
- [call(generateSignedMessage), undefined],
199
222
  [select(choseToRestoreAccountSelector), true],
223
+ [call(generateSignedMessage), undefined],
200
224
  [call(retrieveSignedMessage), 'some signed message'],
201
225
  [select(walletAddressSelector), '0xabc'],
202
226
  ])
@@ -40,7 +40,7 @@ import { safely } from 'src/utils/safely'
40
40
  import { clearStoredAccounts } from 'src/web3/KeychainAccounts'
41
41
  import { getKeychainAccounts } from 'src/web3/contracts'
42
42
  import networkConfig from 'src/web3/networkConfig'
43
- import { getOrCreateAccount, getWalletAddress, unlockAccount } from 'src/web3/saga'
43
+ import { createAccount, getWalletAddress, unlockAccount } from 'src/web3/saga'
44
44
  import { walletAddressSelector } from 'src/web3/selectors'
45
45
  import { call, put, select, spawn, take, takeLeading } from 'typed-redux-saga'
46
46
  const TAG = 'account/saga'
@@ -75,10 +75,14 @@ export function* initializeAccountSaga() {
75
75
  Logger.debug(TAG + '@initializeAccountSaga', 'Creating account')
76
76
  try {
77
77
  AppAnalytics.track(OnboardingEvents.initialize_account_start)
78
- yield* call(getOrCreateAccount)
79
- yield* call(generateSignedMessage)
80
78
 
81
79
  const choseToRestoreAccount = yield* select(choseToRestoreAccountSelector)
80
+ if (!choseToRestoreAccount) {
81
+ yield* call(createAccount)
82
+ }
83
+
84
+ yield* call(generateSignedMessage)
85
+
82
86
  if (choseToRestoreAccount) {
83
87
  yield* call(handlePreviouslyVerifiedPhoneNumber)
84
88
  }
@@ -1,7 +1,7 @@
1
1
  import { createClient } from '@segment/analytics-react-native'
2
2
  import { PincodeType } from 'src/account/reducer'
3
3
  import AppAnalyticsModule from 'src/analytics/AppAnalytics'
4
- import { OnboardingEvents } from 'src/analytics/Events'
4
+ import { NavigationEvents, OnboardingEvents } from 'src/analytics/Events'
5
5
  import * as config from 'src/config'
6
6
  import { store } from 'src/redux/store'
7
7
  import StatsigClientSingleton from 'src/statsig/client'
@@ -37,6 +37,29 @@ jest.mock('src/web3/networkConfig', () => {
37
37
  }
38
38
  })
39
39
 
40
+ const mockMixpanelInit = jest.fn().mockResolvedValue(undefined)
41
+ const mockMixpanelTrack = jest.fn()
42
+ const mockMixpanelIdentify = jest.fn().mockResolvedValue(undefined)
43
+ const mockMixpanelReset = jest.fn()
44
+ const mockMixpanelFlush = jest.fn()
45
+ const mockMixpanelPeopleSet = jest.fn()
46
+ const mockMixpanelOptInTracking = jest.fn()
47
+ const mockMixpanelOptOutTracking = jest.fn()
48
+ const mockMixpanelConstructor = jest.fn()
49
+
50
+ jest.mock('mixpanel-react-native', () => ({
51
+ Mixpanel: mockMixpanelConstructor.mockImplementation(() => ({
52
+ init: mockMixpanelInit,
53
+ track: mockMixpanelTrack,
54
+ identify: mockMixpanelIdentify,
55
+ reset: mockMixpanelReset,
56
+ flush: mockMixpanelFlush,
57
+ getPeople: jest.fn(() => ({ set: mockMixpanelPeopleSet })),
58
+ optInTracking: mockMixpanelOptInTracking,
59
+ optOutTracking: mockMixpanelOptOutTracking,
60
+ })),
61
+ }))
62
+
40
63
  const mockDeviceId = 'abc-def-123' // mocked in __mocks__/react-native-device-info.ts (but importing from that file causes weird errors)
41
64
  const expectedSessionId = '453e535d43b22002185f316d5b41561010d9224580bfb608da132e74b128227a'
42
65
  const mockWalletAddress = '0x12AE66CDc592e10B60f9097a7b0D3C59fce29876' // deliberately using checksummed version here
@@ -206,6 +229,8 @@ describe('AppAnalytics', () => {
206
229
  mockConfig.STATSIG_API_KEY = 'statsig-key'
207
230
  mockConfig.STATSIG_ENABLED = true
208
231
  mockConfig.SEGMENT_API_KEY = 'segment-key'
232
+ mockConfig.MIXPANEL_TOKEN = 'mixpanel-token'
233
+ mockConfig.MIXPANEL_ENABLED = true
209
234
  mockConfig.ENABLED_NETWORK_IDS = ['celo-alfajores']
210
235
  mockStore.getState.mockImplementation(() => state)
211
236
  })
@@ -238,27 +263,55 @@ describe('AppAnalytics', () => {
238
263
  await AppAnalytics.init()
239
264
  expect(StatsigClientSingleton.initialize).not.toHaveBeenCalled()
240
265
  })
266
+
267
+ it('initializes Mixpanel when MIXPANEL_TOKEN is present', async () => {
268
+ await AppAnalytics.init()
269
+ expect(mockMixpanelConstructor).toHaveBeenCalledWith('mixpanel-token', false, true)
270
+ expect(mockMixpanelInit).toHaveBeenCalledWith(false, undefined, undefined)
271
+ })
272
+
273
+ it('does not initialize Mixpanel if MIXPANEL_TOKEN is not present', async () => {
274
+ mockConfig.MIXPANEL_TOKEN = undefined
275
+ mockConfig.MIXPANEL_ENABLED = false
276
+ await AppAnalytics.init()
277
+ expect(mockMixpanelConstructor).not.toHaveBeenCalled()
278
+ })
279
+
280
+ it('initializes Mixpanel with custom API host when provided', async () => {
281
+ mockConfig.MIXPANEL_API_HOST = 'https://api-eu.mixpanel.com'
282
+ await AppAnalytics.init()
283
+ expect(mockMixpanelInit).toHaveBeenCalledWith(false, undefined, 'https://api-eu.mixpanel.com')
284
+ })
241
285
  })
242
286
 
243
287
  it('delays identify calls until async init has finished', async () => {
244
288
  AppAnalytics.identify('0xUSER', { someUserProp: 'testValue' })
245
289
  expect(mockSegmentClient.identify).not.toHaveBeenCalled()
290
+ expect(mockMixpanelConstructor).not.toHaveBeenCalled()
246
291
 
247
292
  await AppAnalytics.init()
248
293
  // Now that init has finished identify should have been called
249
294
  expect(mockSegmentClient.identify).toHaveBeenCalledWith('0xUSER', { someUserProp: 'testValue' })
295
+ expect(mockMixpanelIdentify).toHaveBeenCalledWith('0xUSER')
296
+ expect(mockMixpanelPeopleSet).toHaveBeenCalledWith({ someUserProp: 'testValue' })
250
297
 
251
298
  // And now test that identify calls go trough directly
252
299
  mockSegmentClient.identify.mockClear()
300
+ mockMixpanelIdentify.mockClear()
301
+ mockMixpanelPeopleSet.mockClear()
253
302
  AppAnalytics.identify('0xUSER2', { someUserProp: 'testValue2' })
254
303
  expect(mockSegmentClient.identify).toHaveBeenCalledWith('0xUSER2', {
255
304
  someUserProp: 'testValue2',
256
305
  })
306
+ expect(mockMixpanelIdentify).toHaveBeenCalledWith('0xUSER2')
307
+ await mockMixpanelIdentify.mock.results[0].value // await the identify() promise
308
+ expect(mockMixpanelPeopleSet).toHaveBeenCalledWith({ someUserProp: 'testValue2' })
257
309
  })
258
310
 
259
311
  it('delays track calls until async init has finished', async () => {
260
312
  AppAnalytics.track(OnboardingEvents.pin_invalid, { error: 'some error' })
261
313
  expect(mockSegmentClient.track).not.toHaveBeenCalled()
314
+ expect(mockMixpanelConstructor).not.toHaveBeenCalled()
262
315
 
263
316
  await AppAnalytics.init()
264
317
  // Now that init has finished track should have been called
@@ -267,32 +320,52 @@ describe('AppAnalytics', () => {
267
320
  ...defaultProperties,
268
321
  error: 'some error',
269
322
  })
323
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
324
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(OnboardingEvents.pin_invalid, {
325
+ ...defaultProperties,
326
+ error: 'some error',
327
+ })
270
328
 
271
329
  // And now test that track calls go trough directly
272
330
  mockSegmentClient.track.mockClear()
331
+ mockMixpanelTrack.mockClear()
273
332
  AppAnalytics.track(OnboardingEvents.pin_invalid, { error: 'some error' })
274
333
  expect(mockSegmentClient.track).toHaveBeenCalledTimes(1)
275
334
  expect(mockSegmentClient.track).toHaveBeenCalledWith(OnboardingEvents.pin_invalid, {
276
335
  ...defaultProperties,
277
336
  error: 'some error',
278
337
  })
338
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
339
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(OnboardingEvents.pin_invalid, {
340
+ ...defaultProperties,
341
+ error: 'some error',
342
+ })
279
343
  })
280
344
 
281
345
  it('delays screen calls until async init has finished', async () => {
282
346
  AppAnalytics.page('Some Page', { someProp: 'testValue' })
283
347
  expect(mockSegmentClient.screen).not.toHaveBeenCalled()
348
+ expect(mockMixpanelConstructor).not.toHaveBeenCalled()
284
349
 
285
350
  await AppAnalytics.init()
286
- // Now that init has finished identify should have been called
351
+ // Now that init has finished screen should have been called
287
352
  expect(mockSegmentClient.screen).toHaveBeenCalledTimes(1)
288
353
  expect(mockSegmentClient.screen).toHaveBeenCalledWith('Some Page', {
289
354
  ...defaultProperties,
290
355
  sCurrentScreenId: 'Some Page',
291
356
  someProp: 'testValue',
292
357
  })
358
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
359
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(NavigationEvents.screen_viewed, {
360
+ screen_name: 'Some Page',
361
+ ...defaultProperties,
362
+ sCurrentScreenId: 'Some Page',
363
+ someProp: 'testValue',
364
+ })
293
365
 
294
366
  // And now test that page calls go trough directly
295
367
  mockSegmentClient.screen.mockClear()
368
+ mockMixpanelTrack.mockClear()
296
369
  AppAnalytics.page('Some Page2', { someProp: 'testValue2' })
297
370
  expect(mockSegmentClient.screen).toHaveBeenCalledTimes(1)
298
371
  expect(mockSegmentClient.screen).toHaveBeenCalledWith('Some Page2', {
@@ -301,6 +374,14 @@ describe('AppAnalytics', () => {
301
374
  someProp: 'testValue2',
302
375
  sPrevScreenId: 'Some Page',
303
376
  })
377
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
378
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(NavigationEvents.screen_viewed, {
379
+ screen_name: 'Some Page2',
380
+ ...defaultProperties,
381
+ sCurrentScreenId: 'Some Page2',
382
+ someProp: 'testValue2',
383
+ sPrevScreenId: 'Some Page',
384
+ })
304
385
  })
305
386
 
306
387
  it('adds super properties to all tracked events', async () => {
@@ -311,6 +392,11 @@ describe('AppAnalytics', () => {
311
392
  ...defaultProperties,
312
393
  error: 'some error',
313
394
  })
395
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
396
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(OnboardingEvents.pin_invalid, {
397
+ ...defaultProperties,
398
+ error: 'some error',
399
+ })
314
400
  })
315
401
 
316
402
  it('adds super properties to all screen events', async () => {
@@ -322,6 +408,36 @@ describe('AppAnalytics', () => {
322
408
  someProp: 'someValue',
323
409
  sCurrentScreenId: 'ScreenA',
324
410
  })
411
+ expect(mockMixpanelTrack).toHaveBeenCalledTimes(1)
412
+ expect(mockMixpanelTrack).toHaveBeenCalledWith(NavigationEvents.screen_viewed, {
413
+ screen_name: 'ScreenA',
414
+ ...defaultProperties,
415
+ someProp: 'someValue',
416
+ sCurrentScreenId: 'ScreenA',
417
+ })
418
+ })
419
+
420
+ it('resets both Segment and Mixpanel', async () => {
421
+ await AppAnalytics.init()
422
+ await AppAnalytics.reset()
423
+ expect(mockSegmentClient.flush).toHaveBeenCalled()
424
+ expect(mockSegmentClient.reset).toHaveBeenCalled()
425
+ expect(mockMixpanelFlush).toHaveBeenCalled()
426
+ expect(mockMixpanelReset).toHaveBeenCalled()
427
+ })
428
+
429
+ it('calls optOutTracking when analytics is disabled', async () => {
430
+ await AppAnalytics.init()
431
+ AppAnalytics.setAnalyticsEnabled(false)
432
+ expect(mockMixpanelOptOutTracking).toHaveBeenCalled()
433
+ expect(mockMixpanelOptInTracking).not.toHaveBeenCalled()
434
+ })
435
+
436
+ it('calls optInTracking when analytics is enabled', async () => {
437
+ await AppAnalytics.init()
438
+ AppAnalytics.setAnalyticsEnabled(true)
439
+ expect(mockMixpanelOptInTracking).toHaveBeenCalled()
440
+ expect(mockMixpanelOptOutTracking).not.toHaveBeenCalled()
325
441
  })
326
442
 
327
443
  it('returns a different sessionId if the time is different', async () => {
@@ -2,12 +2,12 @@ import { createClient, SegmentClient } from '@segment/analytics-react-native'
2
2
  import { AdjustPlugin } from '@segment/analytics-react-native-plugin-adjust'
3
3
  import { DestinationFiltersPlugin } from '@segment/analytics-react-native-plugin-destination-filters'
4
4
  import { FirebasePlugin } from '@segment/analytics-react-native-plugin-firebase'
5
- import _ from 'lodash'
5
+ import { Mixpanel } from 'mixpanel-react-native'
6
6
  import { Platform } from 'react-native'
7
7
  import DeviceInfo from 'react-native-device-info'
8
8
  import { check, PERMISSIONS, request, RESULTS } from 'react-native-permissions'
9
9
  import { AsyncStoragePersistor } from 'src/analytics/AsyncStoragePersistor'
10
- import { AppEvents } from 'src/analytics/Events'
10
+ import { AppEvents, NavigationEvents } from 'src/analytics/Events'
11
11
  import { InjectTraits } from 'src/analytics/InjectTraits'
12
12
  import { AnalyticsPropertiesList } from 'src/analytics/Properties'
13
13
  import { getCurrentUserTraits } from 'src/analytics/selectors'
@@ -15,6 +15,9 @@ import {
15
15
  DEFAULT_TESTNET,
16
16
  FIREBASE_ENABLED,
17
17
  isE2EEnv,
18
+ MIXPANEL_API_HOST,
19
+ MIXPANEL_ENABLED,
20
+ MIXPANEL_TOKEN,
18
21
  SEGMENT_API_KEY,
19
22
  STATSIG_ENABLED,
20
23
  STATSIG_ENV,
@@ -23,6 +26,7 @@ import { store } from 'src/redux/store'
23
26
  import StatsigClientSingleton from 'src/statsig/client'
24
27
  import { ensureError } from 'src/utils/ensureError'
25
28
  import Logger from 'src/utils/Logger'
29
+ import { sanitizeProperties } from 'src/utils/serialization'
26
30
  import { sha256 } from 'viem'
27
31
 
28
32
  const TAG = 'AppAnalytics'
@@ -94,6 +98,7 @@ class AppAnalytics {
94
98
  private currentScreenId: string | undefined
95
99
  private prevScreenId: string | undefined
96
100
  private segmentClient: SegmentClient | undefined
101
+ private mixpanelClient: Mixpanel | undefined
97
102
 
98
103
  async init() {
99
104
  let uniqueID
@@ -153,6 +158,26 @@ class AppAnalytics {
153
158
  } else {
154
159
  Logger.info(TAG, 'Statsig is not enabled, skipping setup')
155
160
  }
161
+
162
+ if (MIXPANEL_ENABLED && MIXPANEL_TOKEN) {
163
+ try {
164
+ const trackLegacyAutomaticEvents = false
165
+ const useNative = true
166
+ this.mixpanelClient = new Mixpanel(MIXPANEL_TOKEN, trackLegacyAutomaticEvents, useNative)
167
+
168
+ const optOutTrackingDefault = !this.isEnabled()
169
+ const superProperties = undefined
170
+ const serverURL = MIXPANEL_API_HOST
171
+ await this.mixpanelClient.init(optOutTrackingDefault, superProperties, serverURL)
172
+
173
+ Logger.info(TAG, 'Mixpanel initialized!')
174
+ } catch (err) {
175
+ const error = ensureError(err)
176
+ Logger.error(TAG, `Mixpanel setup error: ${error.message}\n`, error)
177
+ }
178
+ } else {
179
+ Logger.info(TAG, 'Mixpanel token not present, skipping setup')
180
+ }
156
181
  }
157
182
 
158
183
  isEnabled() {
@@ -178,6 +203,16 @@ class AppAnalytics {
178
203
  return this.sessionId
179
204
  }
180
205
 
206
+ setAnalyticsEnabled(enabled: boolean) {
207
+ if (this.mixpanelClient) {
208
+ if (enabled) {
209
+ this.mixpanelClient.optInTracking()
210
+ } else {
211
+ this.mixpanelClient.optOutTracking()
212
+ }
213
+ }
214
+ }
215
+
181
216
  track<EventName extends keyof AnalyticsPropertiesList>(
182
217
  ...args: undefined extends AnalyticsPropertiesList[EventName]
183
218
  ? [EventName] | [EventName, AnalyticsPropertiesList[EventName]]
@@ -190,15 +225,10 @@ class AppAnalytics {
190
225
  return
191
226
  }
192
227
 
193
- if (!this.segmentClient) {
194
- Logger.debug(TAG, `segmentClient undefined, not tracking event ${eventName}`)
195
- return
196
- }
197
-
198
- const props: {} = {
228
+ const props = sanitizeProperties({
199
229
  ...this.getSuperProps(),
200
230
  ...eventProperties,
201
- }
231
+ })
202
232
 
203
233
  if (__DEV__) {
204
234
  Logger.debug(TAG, `Tracking event ${eventName} with properties:`, props)
@@ -206,9 +236,17 @@ class AppAnalytics {
206
236
  Logger.info(TAG, `Tracking event ${eventName}`)
207
237
  }
208
238
 
209
- this.segmentClient.track(eventName, props).catch((err) => {
210
- Logger.error(TAG, `Failed to track event ${eventName}`, err)
211
- })
239
+ // Track to Segment
240
+ if (this.segmentClient) {
241
+ this.segmentClient.track(eventName, props).catch((err) => {
242
+ Logger.error(TAG, `Failed to track event ${eventName} to Segment`, err)
243
+ })
244
+ }
245
+
246
+ // Track to Mixpanel
247
+ if (this.mixpanelClient) {
248
+ this.mixpanelClient.track(eventName, props)
249
+ }
212
250
  }
213
251
 
214
252
  identify(userID: string | null, traits: {}) {
@@ -222,17 +260,27 @@ class AppAnalytics {
222
260
  return
223
261
  }
224
262
 
225
- if (!this.segmentClient) {
226
- Logger.debug(TAG, `segmentClient is undefined, not tracking user ${userID}`)
227
- return
263
+ const safeTraits = sanitizeProperties(traits)
264
+
265
+ // Identify in Segment
266
+ if (this.segmentClient) {
267
+ this.segmentClient.identify(userID, safeTraits).catch((err) => {
268
+ Logger.error(TAG, `Failed to identify user ${userID} in Segment`, err)
269
+ throw err
270
+ })
228
271
  }
229
- // The firebase segment plugin can't handle null or undefined values
230
- const safeTraits = _.omitBy(traits, _.isNil)
231
272
 
232
- this.segmentClient.identify(userID, safeTraits).catch((err) => {
233
- Logger.error(TAG, `Failed to identify user ${userID}`, err)
234
- throw err
235
- })
273
+ // Identify in Mixpanel
274
+ if (this.mixpanelClient) {
275
+ this.mixpanelClient
276
+ .identify(userID)
277
+ .then(() => {
278
+ this.mixpanelClient!.getPeople().set(safeTraits)
279
+ })
280
+ .catch((err) => {
281
+ Logger.error(TAG, `Failed to identify user ${userID} in Mixpanel`, err)
282
+ })
283
+ }
236
284
  }
237
285
 
238
286
  page(screenId: string, eventProperties = {}) {
@@ -241,36 +289,47 @@ class AppAnalytics {
241
289
  return
242
290
  }
243
291
 
244
- if (!this.segmentClient) {
245
- Logger.debug(TAG, `segmentClient is undefined, not tracking screen ${screenId}`)
246
- return
247
- }
248
-
249
292
  if (screenId !== this.currentScreenId) {
250
293
  this.prevScreenId = this.currentScreenId
251
294
  this.currentScreenId = screenId
252
295
  }
253
296
 
254
- const props: {} = {
297
+ const props = sanitizeProperties({
255
298
  ...this.getSuperProps(),
256
299
  ...eventProperties,
300
+ })
301
+
302
+ // Track screen in Segment
303
+ if (this.segmentClient) {
304
+ this.segmentClient.screen(screenId, props).catch((err) => {
305
+ Logger.error(TAG, 'Error tracking page in Segment', err)
306
+ })
257
307
  }
258
308
 
259
- this.segmentClient.screen(screenId, props).catch((err) => {
260
- Logger.error(TAG, 'Error tracking page', err)
261
- })
309
+ // Track screen in Mixpanel
310
+ if (this.mixpanelClient) {
311
+ this.mixpanelClient.track(NavigationEvents.screen_viewed, {
312
+ screen_name: screenId,
313
+ ...props,
314
+ })
315
+ }
262
316
  }
263
317
 
264
318
  async reset() {
265
- if (!this.segmentClient) {
266
- Logger.debug(TAG, `segmentClient is undefined, not resetting`)
267
- return
319
+ // Reset Segment
320
+ if (this.segmentClient) {
321
+ try {
322
+ await this.segmentClient.flush()
323
+ await this.segmentClient.reset()
324
+ } catch (error) {
325
+ Logger.error(TAG, 'Error resetting Segment analytics', error)
326
+ }
268
327
  }
269
- try {
270
- await this.segmentClient.flush()
271
- await this.segmentClient.reset()
272
- } catch (error) {
273
- Logger.error(TAG, 'Error resetting analytics', error)
328
+
329
+ // Reset Mixpanel
330
+ if (this.mixpanelClient) {
331
+ this.mixpanelClient.flush()
332
+ this.mixpanelClient.reset()
274
333
  }
275
334
  }
276
335
 
@@ -451,6 +451,7 @@ export enum PerformanceEvents {
451
451
 
452
452
  export enum NavigationEvents {
453
453
  navigator_not_ready = 'navigator_not_ready',
454
+ screen_viewed = 'screen_viewed',
454
455
  }
455
456
 
456
457
  export enum WalletConnectEvents {
@@ -930,6 +930,9 @@ interface PerformanceProperties {
930
930
 
931
931
  interface NavigationProperties {
932
932
  [NavigationEvents.navigator_not_ready]: undefined
933
+ [NavigationEvents.screen_viewed]: {
934
+ screen_name: string
935
+ }
933
936
  }
934
937
 
935
938
  export interface WalletConnect1Properties {
@@ -430,6 +430,7 @@ export const eventDocs: Record<AnalyticsEventType, string> = {
430
430
  [ContractKitEvents.init_contractkit_finish]: ``,
431
431
  [PerformanceEvents.redux_store_size]: ``,
432
432
  [NavigationEvents.navigator_not_ready]: ``,
433
+ [NavigationEvents.screen_viewed]: `When a screen is viewed in the app`,
433
434
 
434
435
  // Events related to Points program
435
436
  [PointsEvents.points_discover_press]: `when points card is pressed in the discover tab`,
@@ -2,8 +2,9 @@ import { expectSaga } from 'redux-saga-test-plan'
2
2
  import { dynamic } from 'redux-saga-test-plan/providers'
3
3
  import { select } from 'redux-saga/effects'
4
4
  import AppAnalytics from 'src/analytics/AppAnalytics'
5
- import { updateUserTraits } from 'src/analytics/saga'
5
+ import { handleSetAnalyticsEnabled, updateUserTraits } from 'src/analytics/saga'
6
6
  import { getCurrentUserTraits } from 'src/analytics/selectors'
7
+ import { Actions } from 'src/app/actions'
7
8
 
8
9
  jest.mock('src/config', () => ({
9
10
  ...jest.requireActual('src/config'),
@@ -55,3 +56,23 @@ describe(updateUserTraits, () => {
55
56
  })
56
57
  })
57
58
  })
59
+
60
+ describe(handleSetAnalyticsEnabled, () => {
61
+ it('calls setAnalyticsEnabled with the enabled value', async () => {
62
+ await expectSaga(handleSetAnalyticsEnabled, {
63
+ type: Actions.SET_ANALYTICS_ENABLED,
64
+ enabled: false,
65
+ }).silentRun()
66
+
67
+ expect(AppAnalytics.setAnalyticsEnabled).toHaveBeenCalledWith(false)
68
+ })
69
+
70
+ it('calls setAnalyticsEnabled when analytics is re-enabled', async () => {
71
+ await expectSaga(handleSetAnalyticsEnabled, {
72
+ type: Actions.SET_ANALYTICS_ENABLED,
73
+ enabled: true,
74
+ }).silentRun()
75
+
76
+ expect(AppAnalytics.setAnalyticsEnabled).toHaveBeenCalledWith(true)
77
+ })
78
+ })
@@ -1,6 +1,8 @@
1
1
  import AppAnalytics from 'src/analytics/AppAnalytics'
2
2
  import { getCurrentUserTraits } from 'src/analytics/selectors'
3
- import { call, select, spawn, take } from 'typed-redux-saga'
3
+ import { Actions, SetAnalyticsEnabled } from 'src/app/actions'
4
+ import { safely } from 'src/utils/safely'
5
+ import { call, select, spawn, take, takeEvery } from 'typed-redux-saga'
4
6
 
5
7
  export function* updateUserTraits() {
6
8
  let prevTraits
@@ -16,6 +18,15 @@ export function* updateUserTraits() {
16
18
  }
17
19
  }
18
20
 
21
+ export function* handleSetAnalyticsEnabled(action: SetAnalyticsEnabled) {
22
+ yield* call([AppAnalytics, 'setAnalyticsEnabled'], action.enabled)
23
+ }
24
+
25
+ function* watchAnalyticsEnabled() {
26
+ yield* takeEvery(Actions.SET_ANALYTICS_ENABLED, safely(handleSetAnalyticsEnabled))
27
+ }
28
+
19
29
  export function* analyticsSaga() {
20
30
  yield* spawn(updateUserTraits)
31
+ yield* spawn(watchAnalyticsEnabled)
21
32
  }
@@ -54,7 +54,7 @@ interface DeepLinkDeferred {
54
54
  isSecureOrigin: boolean
55
55
  }
56
56
 
57
- interface SetAnalyticsEnabled {
57
+ export interface SetAnalyticsEnabled {
58
58
  type: Actions.SET_ANALYTICS_ENABLED
59
59
  enabled: boolean
60
60
  }
package/src/config.ts CHANGED
@@ -97,6 +97,9 @@ export const ALCHEMY_API_KEY = experimentalConfig.alchemyApiKey
97
97
  export const STATSIG_API_KEY = appConfig.features?.statsig?.apiKey
98
98
  export const STATSIG_ENABLED = !isE2EEnv && !!STATSIG_API_KEY
99
99
  export const SEGMENT_API_KEY = appConfig.features?.segment?.apiKey
100
+ export const MIXPANEL_TOKEN = appConfig.features?.mixpanel?.token
101
+ export const MIXPANEL_API_HOST = appConfig.features?.mixpanel?.apiHost
102
+ export const MIXPANEL_ENABLED = !isE2EEnv && !!MIXPANEL_TOKEN
100
103
  export const AUTH0_CLIENT_ID =
101
104
  DEFAULT_TESTNET === 'mainnet'
102
105
  ? 'FS2sPfMvDBKy0udOoCbc4ao8HakvAR6b'
@@ -169,6 +169,10 @@ export interface PublicAppConfig<tabScreenConfigs extends TabScreenConfig[] = Ta
169
169
  segment?: {
170
170
  apiKey: string
171
171
  }
172
+ mixpanel?: {
173
+ token: string
174
+ apiHost?: string
175
+ }
172
176
  }
173
177
 
174
178
  /**
@@ -11,6 +11,7 @@ import { PathReporter } from 'io-ts/lib/PathReporter'
11
11
  import { DEEP_LINK_URL_SCHEME } from 'src/config'
12
12
  import { LocalCurrencyCode } from 'src/localCurrency/consts'
13
13
  import { AddressType, E164PhoneNumberType } from 'src/utils/io'
14
+ import { sanitizeProperties } from 'src/utils/serialization'
14
15
  import { parse } from 'url'
15
16
 
16
17
  export const UriDataType = ioType({
@@ -36,10 +37,7 @@ enum UriMethod {
36
37
  pay = 'pay',
37
38
  }
38
39
 
39
- // removes undefined parameters for serialization
40
- export const stripUndefined = (obj: object) => JSON.parse(JSON.stringify(obj))
41
-
42
40
  export const urlFromUriData = (data: Partial<UriData>, method: UriMethod = UriMethod.pay) => {
43
- const params = new URLSearchParams(stripUndefined(data))
41
+ const params = new URLSearchParams(sanitizeProperties(data))
44
42
  return encodeURI(`${DEEP_LINK_URL_SCHEME}://wallet/${method.toString()}?${params.toString()}`)
45
43
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Recursively strips values that are not useful to send through serialization
3
+ * boundaries (e.g. React Native native bridge, URL params, analytics payloads).
4
+ *
5
+ * Removes: `undefined`, `null`, `NaN`, `Infinity`
6
+ */
7
+ export const sanitizeProperties = <T extends object>(obj: T): Partial<T> =>
8
+ JSON.parse(JSON.stringify(obj), (_key, value) => (value === null ? undefined : value))
@@ -4,6 +4,7 @@ import { call, select } from 'redux-saga/effects'
4
4
  import { generateSignedMessage } from 'src/account/saga'
5
5
  import { ErrorMessages } from 'src/app/ErrorMessages'
6
6
  import { storeMnemonic } from 'src/backup/utils'
7
+ import { clearStoredAccounts } from 'src/web3/KeychainAccounts'
7
8
  import { currentLanguageSelector } from 'src/i18n/selectors'
8
9
  import { getPasswordSaga, retrieveSignedMessage } from 'src/pincode/authentication'
9
10
  import { MnemonicLanguages, MnemonicStrength, generateMnemonic } from 'src/utils/account'
@@ -12,7 +13,7 @@ import {
12
13
  UnlockResult,
13
14
  getConnectedAccount,
14
15
  getConnectedUnlockedAccount,
15
- getOrCreateAccount,
16
+ createAccount,
16
17
  getWalletAddress,
17
18
  unlockAccount,
18
19
  assignAccountFromPrivateKey,
@@ -40,15 +41,7 @@ const state = createMockStore({
40
41
  web3: { account: mockAccount },
41
42
  }).getState()
42
43
 
43
- describe(getOrCreateAccount, () => {
44
- it('returns an existing account', async () => {
45
- await expectSaga(getOrCreateAccount)
46
- .withState(state)
47
- .not.call.fn(generateMnemonic)
48
- .returns('0x0000000000000000000000000000000000007e57')
49
- .run()
50
- })
51
-
44
+ describe(createAccount, () => {
52
45
  it.each`
53
46
  expectedAddress | expectedPrivateDek | mnemonic
54
47
  ${'0xE025583d25Eff2C254999b5904C97bAe9B3F8D83'} | ${'0xb6812219f7003c27cc1ef17c2033c033a38cfc52d83f176a0667086787d59d39'} | ${'avellana novio zona pinza ducha íntimo amante diluir toldo peón ocio encía gen balcón carro lingote millón amasar mármol bondad toser soledad croqueta agosto'}
@@ -57,10 +50,9 @@ describe(getOrCreateAccount, () => {
57
50
  `(
58
51
  'creates a new account $expectedAddress',
59
52
  async ({ expectedAddress, expectedPrivateDek, mnemonic }) => {
60
- await expectSaga(getOrCreateAccount)
53
+ await expectSaga(createAccount)
61
54
  .withState(state)
62
55
  .provide([
63
- [select(currentAccountSelector), null],
64
56
  [matchers.call.fn(generateMnemonic), mnemonic],
65
57
  [
66
58
  call(storeMnemonic, mnemonic, expectedAddress),
@@ -71,6 +63,7 @@ describe(getOrCreateAccount, () => {
71
63
  ],
72
64
  [call(getPasswordSaga, expectedAddress, false, true), 'somePassword'],
73
65
  ])
66
+ .call(clearStoredAccounts)
74
67
  .put(setAccount(expectedAddress))
75
68
  .returns(expectedAddress)
76
69
  .run()
@@ -86,10 +79,9 @@ describe(getOrCreateAccount, () => {
86
79
  `(
87
80
  'creates an account with a mnemonic in $expectedMnemonicLang when app language is $appLang',
88
81
  async ({ appLang, expectedMnemonicLang }) => {
89
- const { returnValue } = await expectSaga(getOrCreateAccount)
82
+ const { returnValue } = await expectSaga(createAccount)
90
83
  .withState(state)
91
84
  .provide([
92
- [select(currentAccountSelector), null],
93
85
  [select(currentLanguageSelector), appLang],
94
86
  [
95
87
  matchers.call.fn(storeMnemonic),
package/src/web3/saga.ts CHANGED
@@ -12,28 +12,23 @@ import Logger from 'src/utils/Logger'
12
12
  import { MnemonicLanguages, MnemonicStrength, generateMnemonic } from 'src/utils/account'
13
13
  import { privateKeyToAddress } from 'src/utils/address'
14
14
  import { ensureError } from 'src/utils/ensureError'
15
+ import { clearStoredAccounts } from 'src/web3/KeychainAccounts'
15
16
  import { Actions, SetAccountAction, setAccount } from 'src/web3/actions'
16
17
  import { UNLOCK_DURATION } from 'src/web3/consts'
17
18
  import { getKeychainAccounts } from 'src/web3/contracts'
18
- import { currentAccountSelector, walletAddressSelector } from 'src/web3/selectors'
19
+ import { walletAddressSelector } from 'src/web3/selectors'
19
20
  import { call, put, select, take } from 'typed-redux-saga'
20
21
  import { RootState } from '../redux/reducers'
21
22
 
22
23
  const TAG = 'web3/saga'
23
24
 
24
- export function* getOrCreateAccount() {
25
- const account = yield* select(currentAccountSelector)
26
- if (account) {
27
- Logger.debug(
28
- TAG + '@getOrCreateAccount',
29
- 'Tried to create account twice, returning the existing one'
30
- )
31
- return account
32
- }
33
-
25
+ export function* createAccount() {
34
26
  let privateKey: string | undefined
35
27
  try {
36
- Logger.debug(TAG + '@getOrCreateAccount', 'Creating a new account')
28
+ Logger.debug(TAG + '@createAccount', 'Clearing stored accounts')
29
+ yield* call(clearStoredAccounts)
30
+
31
+ Logger.debug(TAG + '@createAccount', 'Creating a new account')
37
32
 
38
33
  const mnemonicBitLength = MnemonicStrength.s128_12words
39
34
  const mnemonicLanguage = MnemonicLanguages.english
@@ -45,7 +40,7 @@ export function* getOrCreateAccount() {
45
40
  }
46
41
  let duplicateInMnemonic = checkDuplicate(mnemonic)
47
42
  while (duplicateInMnemonic) {
48
- Logger.debug(TAG + '@getOrCreateAccount', 'Regenerating mnemonic to avoid duplicates')
43
+ Logger.debug(TAG + '@createAccount', 'Regenerating mnemonic to avoid duplicates')
49
44
  mnemonic = yield* call(generateMnemonic, mnemonicBitLength, mnemonicLanguage)
50
45
  duplicateInMnemonic = checkDuplicate(mnemonic)
51
46
  }
@@ -71,7 +66,7 @@ export function* getOrCreateAccount() {
71
66
  } catch (err) {
72
67
  const error = ensureError(err)
73
68
  const sanitizedError = Logger.sanitizeError(error, privateKey)
74
- Logger.error(TAG + '@getOrCreateAccount', 'Error creating account', sanitizedError)
69
+ Logger.error(TAG + '@createAccount', 'Error creating account', sanitizedError)
75
70
  throw new Error(ErrorMessages.ACCOUNT_SETUP_FAILED)
76
71
  }
77
72
  }