@formo/analytics-react-native 0.1.4 → 0.1.6

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.
Files changed (86) hide show
  1. package/lib/commonjs/FormoAnalytics.js +91 -16
  2. package/lib/commonjs/FormoAnalytics.js.map +1 -1
  3. package/lib/commonjs/constants/storage.js +4 -1
  4. package/lib/commonjs/constants/storage.js.map +1 -1
  5. package/lib/commonjs/lib/event/EventFactory.js +6 -13
  6. package/lib/commonjs/lib/event/EventFactory.js.map +1 -1
  7. package/lib/commonjs/lib/installReferrer/index.js +227 -0
  8. package/lib/commonjs/lib/installReferrer/index.js.map +1 -0
  9. package/lib/commonjs/lib/wagmi/WagmiEventHandler.js +1 -6
  10. package/lib/commonjs/lib/wagmi/WagmiEventHandler.js.map +1 -1
  11. package/lib/commonjs/solana/address.js +110 -0
  12. package/lib/commonjs/solana/address.js.map +1 -0
  13. package/lib/commonjs/solana/index.js +28 -0
  14. package/lib/commonjs/solana/index.js.map +1 -0
  15. package/lib/commonjs/solana/types.js +47 -0
  16. package/lib/commonjs/solana/types.js.map +1 -0
  17. package/lib/commonjs/types/events.js.map +1 -1
  18. package/lib/commonjs/utils/address.js +60 -6
  19. package/lib/commonjs/utils/address.js.map +1 -1
  20. package/lib/commonjs/utils/trafficSource.js +61 -0
  21. package/lib/commonjs/utils/trafficSource.js.map +1 -1
  22. package/lib/commonjs/version.js +1 -1
  23. package/lib/module/FormoAnalytics.js +93 -18
  24. package/lib/module/FormoAnalytics.js.map +1 -1
  25. package/lib/module/constants/storage.js +3 -0
  26. package/lib/module/constants/storage.js.map +1 -1
  27. package/lib/module/lib/event/EventFactory.js +7 -14
  28. package/lib/module/lib/event/EventFactory.js.map +1 -1
  29. package/lib/module/lib/installReferrer/index.js +221 -0
  30. package/lib/module/lib/installReferrer/index.js.map +1 -0
  31. package/lib/module/lib/wagmi/WagmiEventHandler.js +1 -6
  32. package/lib/module/lib/wagmi/WagmiEventHandler.js.map +1 -1
  33. package/lib/module/solana/address.js +100 -0
  34. package/lib/module/solana/address.js.map +1 -0
  35. package/lib/module/solana/index.js +3 -0
  36. package/lib/module/solana/index.js.map +1 -0
  37. package/lib/module/solana/types.js +40 -0
  38. package/lib/module/solana/types.js.map +1 -0
  39. package/lib/module/types/events.js.map +1 -1
  40. package/lib/module/utils/address.js +58 -6
  41. package/lib/module/utils/address.js.map +1 -1
  42. package/lib/module/utils/trafficSource.js +59 -0
  43. package/lib/module/utils/trafficSource.js.map +1 -1
  44. package/lib/module/version.js +1 -1
  45. package/lib/typescript/FormoAnalytics.d.ts +23 -4
  46. package/lib/typescript/FormoAnalytics.d.ts.map +1 -1
  47. package/lib/typescript/constants/storage.d.ts +1 -0
  48. package/lib/typescript/constants/storage.d.ts.map +1 -1
  49. package/lib/typescript/lib/event/EventFactory.d.ts +1 -1
  50. package/lib/typescript/lib/event/EventFactory.d.ts.map +1 -1
  51. package/lib/typescript/lib/event/types.d.ts +1 -1
  52. package/lib/typescript/lib/event/types.d.ts.map +1 -1
  53. package/lib/typescript/lib/installReferrer/index.d.ts +36 -0
  54. package/lib/typescript/lib/installReferrer/index.d.ts.map +1 -0
  55. package/lib/typescript/lib/wagmi/WagmiEventHandler.d.ts +0 -1
  56. package/lib/typescript/lib/wagmi/WagmiEventHandler.d.ts.map +1 -1
  57. package/lib/typescript/solana/address.d.ts +42 -0
  58. package/lib/typescript/solana/address.d.ts.map +1 -0
  59. package/lib/typescript/solana/index.d.ts +3 -0
  60. package/lib/typescript/solana/index.d.ts.map +1 -0
  61. package/lib/typescript/solana/types.d.ts +34 -0
  62. package/lib/typescript/solana/types.d.ts.map +1 -0
  63. package/lib/typescript/types/base.d.ts +38 -1
  64. package/lib/typescript/types/base.d.ts.map +1 -1
  65. package/lib/typescript/types/events.d.ts +0 -1
  66. package/lib/typescript/types/events.d.ts.map +1 -1
  67. package/lib/typescript/utils/address.d.ts +24 -4
  68. package/lib/typescript/utils/address.d.ts.map +1 -1
  69. package/lib/typescript/utils/trafficSource.d.ts +16 -0
  70. package/lib/typescript/utils/trafficSource.d.ts.map +1 -1
  71. package/lib/typescript/version.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/src/FormoAnalytics.ts +112 -16
  74. package/src/constants/storage.ts +3 -0
  75. package/src/lib/event/EventFactory.ts +7 -15
  76. package/src/lib/event/types.ts +0 -1
  77. package/src/lib/installReferrer/index.ts +262 -0
  78. package/src/lib/wagmi/WagmiEventHandler.ts +0 -4
  79. package/src/solana/address.ts +122 -0
  80. package/src/solana/index.ts +2 -0
  81. package/src/solana/types.ts +46 -0
  82. package/src/types/base.ts +40 -1
  83. package/src/types/events.ts +0 -1
  84. package/src/utils/address.ts +72 -6
  85. package/src/utils/trafficSource.ts +73 -0
  86. package/src/version.ts +1 -1
@@ -35,8 +35,10 @@ import {
35
35
  TrackingOptions,
36
36
  TransactionStatus,
37
37
  } from "./types";
38
- import { toChecksumAddress, getValidAddress } from "./utils";
39
- import { parseTrafficSource, storeTrafficSource } from "./utils/trafficSource";
38
+ import { validateAddress } from "./utils";
39
+ import { parseTrafficSource, updateStoredTrafficSource } from "./utils/trafficSource";
40
+ import { captureInstallReferrer } from "./lib/installReferrer";
41
+ import { Linking, EmitterSubscription } from "react-native";
40
42
 
41
43
  export class FormoAnalytics implements IFormoAnalytics {
42
44
  private session: FormoAnalyticsSession;
@@ -44,6 +46,7 @@ export class FormoAnalytics implements IFormoAnalytics {
44
46
  private eventQueue: EventQueue;
45
47
  private wagmiHandler?: WagmiEventHandler;
46
48
  private lifecycleManager?: AppLifecycleManager;
49
+ private linkingSubscription?: EmitterSubscription;
47
50
 
48
51
  config: Config;
49
52
  currentChainId?: ChainID;
@@ -128,6 +131,32 @@ export class FormoAnalytics implements IFormoAnalytics {
128
131
 
129
132
  const analytics = new FormoAnalytics(writeKey, options);
130
133
 
134
+ // Capture attribution BEFORE lifecycle tracking so the first
135
+ // Application Installed/Opened events carry utm_*/ref/referrer context.
136
+ //
137
+ // Deep-link initial URL is awaited because Linking.getInitialURL() is a
138
+ // fast native bridge call and we need its result before lifecycle events
139
+ // fire. The url-event subscription is set up synchronously for runtime
140
+ // deep links. Install-referrer capture (Play / AdServices) is fire-and-
141
+ // forget since it involves potentially-slow network I/O on iOS and is
142
+ // best-effort; it'll still populate attribution for subsequent events.
143
+ if (analytics.isAttributionEnabled("deeplinks")) {
144
+ try {
145
+ await analytics.startDeepLinkCapture();
146
+ } catch (error) {
147
+ logger.error("FormoAnalytics: Failed to initialize deep link capture", error);
148
+ }
149
+ }
150
+
151
+ if (analytics.isAttributionEnabled("installReferrer")) {
152
+ captureInstallReferrer({
153
+ customRefParams: analytics.options.referral?.queryParams,
154
+ pathPattern: analytics.options.referral?.pathPattern,
155
+ }).catch((error) => {
156
+ logger.debug("FormoAnalytics: install referrer capture failed", error);
157
+ });
158
+ }
159
+
131
160
  // Initialize lifecycle tracking if enabled
132
161
  // Wrapped in try-catch so a transient storage failure doesn't prevent SDK init
133
162
  if (analytics.isAutocaptureEnabled("lifecycle")) {
@@ -147,6 +176,25 @@ export class FormoAnalytics implements IFormoAnalytics {
147
176
  return analytics;
148
177
  }
149
178
 
179
+ /**
180
+ * Hook into React Native's Linking API to auto-capture traffic source from
181
+ * the launch URL and any subsequent deep-link opens. Awaits the initial URL
182
+ * so attribution is in storage before the first lifecycle event fires.
183
+ */
184
+ private async startDeepLinkCapture(): Promise<void> {
185
+ try {
186
+ const url = await Linking.getInitialURL();
187
+ if (url) this.setTrafficSourceFromUrl(url);
188
+ } catch (error) {
189
+ logger.debug("FormoAnalytics: Linking.getInitialURL failed", error);
190
+ }
191
+
192
+ // Runtime deep links (foreground opens, universal links).
193
+ this.linkingSubscription = Linking.addEventListener("url", (event) => {
194
+ if (event?.url) this.setTrafficSourceFromUrl(event.url);
195
+ });
196
+ }
197
+
150
198
  /**
151
199
  * Track a screen view (mobile equivalent of page view)
152
200
  */
@@ -195,7 +243,10 @@ export class FormoAnalytics implements IFormoAnalytics {
195
243
  this.options.referral?.queryParams,
196
244
  this.options.referral?.pathPattern
197
245
  );
198
- storeTrafficSource(trafficSource);
246
+ // Per-field merge: incoming non-empty fields win, empty fields preserve
247
+ // stored values. A non-marketing deep link (e.g. "myapp://home") with only
248
+ // a referrer will not destroy previously captured utm_*/ref attribution.
249
+ updateStoredTrafficSource(trafficSource);
199
250
  logger.debug("Traffic source set from URL:", trafficSource);
200
251
  }
201
252
 
@@ -220,6 +271,11 @@ export class FormoAnalytics implements IFormoAnalytics {
220
271
  this.lifecycleManager = undefined;
221
272
  }
222
273
 
274
+ if (this.linkingSubscription) {
275
+ this.linkingSubscription.remove();
276
+ this.linkingSubscription = undefined;
277
+ }
278
+
223
279
  if (this.wagmiHandler) {
224
280
  this.wagmiHandler.cleanup();
225
281
  this.wagmiHandler = undefined;
@@ -250,8 +306,8 @@ export class FormoAnalytics implements IFormoAnalytics {
250
306
  return;
251
307
  }
252
308
 
253
- const checksummedAddress = this.validateAndChecksumAddress(address);
254
- if (!checksummedAddress) {
309
+ const validatedAddress = this.validateAndChecksumAddress(address, chainId);
310
+ if (!validatedAddress) {
255
311
  logger.warn(`Connect: Invalid address provided ("${address}")`);
256
312
  return;
257
313
  }
@@ -259,14 +315,14 @@ export class FormoAnalytics implements IFormoAnalytics {
259
315
  // Track event before updating state so connect events TO excluded chains are tracked
260
316
  await this.trackEvent(
261
317
  EventType.CONNECT,
262
- { chainId, address: checksummedAddress },
318
+ { chainId, address: validatedAddress },
263
319
  properties,
264
320
  context,
265
321
  callback
266
322
  );
267
323
 
268
324
  this.currentChainId = chainId;
269
- this.currentAddress = checksummedAddress;
325
+ this.currentAddress = validatedAddress;
270
326
  }
271
327
 
272
328
  /**
@@ -345,13 +401,11 @@ export class FormoAnalytics implements IFormoAnalytics {
345
401
  chainId,
346
402
  address,
347
403
  message,
348
- signatureHash,
349
404
  }: {
350
405
  status: SignatureStatus;
351
406
  chainId?: ChainID;
352
407
  address: Address;
353
408
  message: string;
354
- signatureHash?: string;
355
409
  },
356
410
  properties?: IFormoEventProperties,
357
411
  context?: IFormoEventContext,
@@ -368,7 +422,6 @@ export class FormoAnalytics implements IFormoAnalytics {
368
422
  ...(chainId !== undefined && chainId !== null && { chainId }),
369
423
  address,
370
424
  message,
371
- ...(signatureHash && { signatureHash }),
372
425
  },
373
426
  properties,
374
427
  context,
@@ -458,6 +511,8 @@ export class FormoAnalytics implements IFormoAnalytics {
458
511
  return;
459
512
  }
460
513
  this.currentAddress = validAddress;
514
+ // Note: validateAddress returns Solana addresses unchanged (Base58, case-sensitive)
515
+ // and EVM addresses checksummed.
461
516
  } else {
462
517
  this.currentAddress = undefined;
463
518
  }
@@ -566,10 +621,19 @@ export class FormoAnalytics implements IFormoAnalytics {
566
621
  }
567
622
 
568
623
  /**
569
- * Check if autocapture is enabled for event type
624
+ * Check if autocapture is enabled for a given event type.
625
+ * Applies only to event-generating behaviors (wallet events, lifecycle
626
+ * events). Attribution is controlled separately via `options.attribution`
627
+ * because it enriches events rather than generating them.
570
628
  */
571
629
  public isAutocaptureEnabled(
572
- eventType: "connect" | "disconnect" | "signature" | "transaction" | "chain" | "lifecycle"
630
+ eventType:
631
+ | "connect"
632
+ | "disconnect"
633
+ | "signature"
634
+ | "transaction"
635
+ | "chain"
636
+ | "lifecycle"
573
637
  ): boolean {
574
638
  if (this.options.autocapture === undefined) {
575
639
  return true;
@@ -590,6 +654,32 @@ export class FormoAnalytics implements IFormoAnalytics {
590
654
  return true;
591
655
  }
592
656
 
657
+ /**
658
+ * Check if an attribution source is enabled. Attribution is not an event
659
+ * type — it decorates every tracked event with `utm_*`, `ref`, and
660
+ * `referrer` context fields.
661
+ */
662
+ public isAttributionEnabled(
663
+ source: "deeplinks" | "installReferrer"
664
+ ): boolean {
665
+ if (this.options.attribution === undefined) {
666
+ return true;
667
+ }
668
+
669
+ if (typeof this.options.attribution === "boolean") {
670
+ return this.options.attribution;
671
+ }
672
+
673
+ if (
674
+ this.options.attribution !== null &&
675
+ typeof this.options.attribution === "object"
676
+ ) {
677
+ return this.options.attribution[source] !== false;
678
+ }
679
+
680
+ return true;
681
+ }
682
+
593
683
  /**
594
684
  * Internal method to track events
595
685
  * This is the single enforcement point for shouldTrack() - all public tracking
@@ -669,11 +759,17 @@ export class FormoAnalytics implements IFormoAnalytics {
669
759
  }
670
760
 
671
761
  /**
672
- * Validate and checksum address
762
+ * Validate and normalize an address for the given chain.
763
+ *
764
+ * EVM addresses are returned in EIP-55 checksum format.
765
+ * Solana addresses are returned as-is (Base58 is case-sensitive).
766
+ * When chainId is omitted, EVM is tried first with Solana as fallback.
673
767
  */
674
- private validateAndChecksumAddress(address: string): Address | undefined {
675
- const validAddress = getValidAddress(address);
676
- return validAddress ? toChecksumAddress(validAddress) : undefined;
768
+ private validateAndChecksumAddress(
769
+ address: string,
770
+ chainId?: ChainID
771
+ ): Address | undefined {
772
+ return validateAddress(address, chainId);
677
773
  }
678
774
 
679
775
  /**
@@ -5,6 +5,9 @@ export const STORAGE_PREFIX = "formo_rn_";
5
5
  export const LOCAL_ANONYMOUS_ID_KEY = "anonymous_id";
6
6
  export const LOCAL_APP_VERSION_KEY = "app_version";
7
7
  export const LOCAL_APP_BUILD_KEY = "app_build";
8
+ // One-shot flag: set once the Install Referrer (Android) or AdServices (iOS)
9
+ // attribution has been fetched, so we never call the native API again.
10
+ export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved";
8
11
 
9
12
  // Session storage keys (cleared on app restart)
10
13
  export const SESSION_USER_ID_KEY = "user_id";
@@ -39,8 +39,7 @@ import {
39
39
  TransactionStatus,
40
40
  } from "../../types";
41
41
  import {
42
- toChecksumAddress,
43
- getValidAddress,
42
+ validateAddress,
44
43
  toSnakeCase,
45
44
  mergeDeepRight,
46
45
  getStoredTrafficSource,
@@ -337,12 +336,11 @@ class EventFactory implements IEventFactory {
337
336
  commonEventData.anonymous_id = generateAnonymousId(LOCAL_ANONYMOUS_ID_KEY);
338
337
 
339
338
  // Handle address - convert undefined to null for consistency
340
- const validAddress = getValidAddress(formoEvent.address);
341
- if (validAddress) {
342
- commonEventData.address = toChecksumAddress(validAddress);
343
- } else {
344
- commonEventData.address = null;
345
- }
339
+ // Try EVM first, then Solana fallback (chainId is not always present here).
340
+ const validAddress = formoEvent.address
341
+ ? validateAddress(formoEvent.address)
342
+ : undefined;
343
+ commonEventData.address = validAddress ?? null;
346
344
 
347
345
  const processedEvent = mergeDeepRight(
348
346
  formoEvent as Record<string, unknown>,
@@ -501,7 +499,6 @@ class EventFactory implements IEventFactory {
501
499
  chainId: ChainID | undefined,
502
500
  address: Address,
503
501
  message: string,
504
- signatureHash?: string,
505
502
  properties?: IFormoEventProperties,
506
503
  context?: IFormoEventContext
507
504
  ): Promise<IFormoEvent> {
@@ -510,7 +507,6 @@ class EventFactory implements IEventFactory {
510
507
  status,
511
508
  ...(chainId !== undefined && chainId !== null && { chainId }),
512
509
  message,
513
- ...(signatureHash && { signatureHash }),
514
510
  ...properties,
515
511
  },
516
512
  address,
@@ -648,7 +644,6 @@ class EventFactory implements IEventFactory {
648
644
  event.chainId,
649
645
  event.address,
650
646
  event.message,
651
- event.signatureHash,
652
647
  event.properties,
653
648
  event.context
654
649
  );
@@ -680,10 +675,7 @@ class EventFactory implements IEventFactory {
680
675
 
681
676
  // Set address if not already set by the specific event generator
682
677
  if (formoEvent.address === undefined || formoEvent.address === null) {
683
- const validAddress = getValidAddress(address);
684
- formoEvent.address = validAddress
685
- ? toChecksumAddress(validAddress)
686
- : null;
678
+ formoEvent.address = address ? validateAddress(address) ?? null : null;
687
679
  }
688
680
  formoEvent.user_id = userId || null;
689
681
 
@@ -66,7 +66,6 @@ export interface IEventFactory {
66
66
  chainId: ChainID | undefined,
67
67
  address: Address,
68
68
  message: string,
69
- signatureHash?: string,
70
69
  properties?: IFormoEventProperties,
71
70
  context?: IFormoEventContext
72
71
  ): Promise<IFormoEvent>;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Install Referrer / attribution capture
3
+ *
4
+ * Populates the existing traffic source fields (utm_source, utm_medium,
5
+ * utm_campaign, utm_term, utm_content, ref, referrer) from platform install
6
+ * attribution APIs on first launch:
7
+ *
8
+ * - Android: Google Play Install Referrer API via react-native-play-install-referrer
9
+ * (optional peer dep). Returns a URL-encoded query string like
10
+ * "utm_source=google&utm_campaign=spring_sale&..." which we parse with
11
+ * parseTrafficSource.
12
+ *
13
+ * - iOS: AdServices attribution via react-native-ad-services-attribution
14
+ * (optional peer dep). Returns an attribution token which we exchange with
15
+ * Apple's AdServices endpoint; the response is mapped onto utm_* fields.
16
+ *
17
+ * Both native modules are lazy-required and the capture silently no-ops when
18
+ * they are not installed (keeps Expo Go and minimal integrations working).
19
+ *
20
+ * Result is merged with mergeTrafficSourceFill so a deep link that arrived
21
+ * via Linking.getInitialURL() takes precedence over install-referrer data.
22
+ *
23
+ * The resolution is one-shot: on success we set LOCAL_INSTALL_REFERRER_RESOLVED_KEY
24
+ * so we never call the native API again (Play returns meaningful data only on
25
+ * the first fetch; Apple only within ~24h of install).
26
+ */
27
+
28
+ import { Platform } from "react-native";
29
+ import { logger } from "../logger";
30
+ import { storage, getStorageManager } from "../storage";
31
+ import { LOCAL_INSTALL_REFERRER_RESOLVED_KEY } from "../../constants/storage";
32
+ import {
33
+ parseTrafficSource,
34
+ mergeTrafficSourceFill,
35
+ } from "../../utils/trafficSource";
36
+ import type { ITrafficSource } from "../../types";
37
+
38
+ // Lazy-load optional native modules. Absence is fine — attribution is best-effort.
39
+ let PlayInstallReferrer: {
40
+ getInstallReferrerInfo: (
41
+ cb: (info: { installReferrer?: string } | null, error?: unknown) => void
42
+ ) => void;
43
+ } | null = null;
44
+
45
+ try {
46
+ PlayInstallReferrer = require("react-native-play-install-referrer")
47
+ .PlayInstallReferrer;
48
+ } catch {
49
+ // Not installed — Android install referrer capture will no-op.
50
+ }
51
+
52
+ let AdServicesAttribution: {
53
+ getAttributionToken: () => Promise<string | null>;
54
+ } | null = null;
55
+
56
+ try {
57
+ const mod = require("react-native-ad-services-attribution");
58
+ AdServicesAttribution = mod.default ?? mod;
59
+ } catch {
60
+ // Not installed — iOS AdServices capture will no-op.
61
+ }
62
+
63
+ export interface CaptureOptions {
64
+ customRefParams?: string[];
65
+ pathPattern?: string;
66
+ }
67
+
68
+ /**
69
+ * Capture install-time attribution and merge into the stored traffic source.
70
+ * One-shot: returns immediately if already resolved on a previous launch.
71
+ */
72
+ export async function captureInstallReferrer(
73
+ options: CaptureOptions = {}
74
+ ): Promise<void> {
75
+ try {
76
+ // The one-shot flag is only useful if it can persist across launches.
77
+ // Without AsyncStorage (MemoryStorage fallback) the flag is lost every
78
+ // restart, so we'd re-hit the native API every cold start. Mirror the
79
+ // lifecycle manager's guard and skip capture entirely in that case.
80
+ const hasPersistentStorage =
81
+ getStorageManager()?.hasPersistentStorage() ?? false;
82
+ if (!hasPersistentStorage) {
83
+ logger.debug(
84
+ "InstallReferrer: persistent storage unavailable, skipping capture"
85
+ );
86
+ return;
87
+ }
88
+
89
+ const resolved = storage().get(LOCAL_INSTALL_REFERRER_RESOLVED_KEY);
90
+ if (resolved === "true") {
91
+ logger.debug("InstallReferrer: already resolved, skipping");
92
+ return;
93
+ }
94
+
95
+ let didResolve = false;
96
+
97
+ if (Platform.OS === "android") {
98
+ didResolve = await captureAndroidReferrer(options);
99
+ } else if (Platform.OS === "ios") {
100
+ didResolve = await captureIOSAttribution();
101
+ } else {
102
+ logger.debug(
103
+ `InstallReferrer: unsupported platform ${Platform.OS}, skipping`
104
+ );
105
+ return;
106
+ }
107
+
108
+ if (didResolve) {
109
+ await storage().setAsync(LOCAL_INSTALL_REFERRER_RESOLVED_KEY, "true");
110
+ }
111
+ } catch (error) {
112
+ // Never let attribution failures break SDK init.
113
+ logger.debug("InstallReferrer: capture failed", error);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Android: call Play Install Referrer API once, parse the returned UTM query
119
+ * string, fill in any empty traffic-source fields.
120
+ */
121
+ async function captureAndroidReferrer(
122
+ options: CaptureOptions
123
+ ): Promise<boolean> {
124
+ if (!PlayInstallReferrer) {
125
+ logger.debug(
126
+ "InstallReferrer: react-native-play-install-referrer not installed, skipping Android capture"
127
+ );
128
+ return false;
129
+ }
130
+
131
+ // Distinguish "native API errored" (retry next launch) from "native API
132
+ // succeeded but no referrer data" (organic install — mark resolved so we
133
+ // don't re-call every launch).
134
+ const result = await new Promise<{
135
+ ok: boolean;
136
+ info: { installReferrer?: string } | null;
137
+ }>((resolve) => {
138
+ try {
139
+ PlayInstallReferrer!.getInstallReferrerInfo((info, error) => {
140
+ if (error) {
141
+ logger.debug("InstallReferrer: Play API error", error);
142
+ resolve({ ok: false, info: null });
143
+ return;
144
+ }
145
+ resolve({ ok: true, info: info ?? null });
146
+ });
147
+ } catch (e) {
148
+ logger.debug("InstallReferrer: Play API threw", e);
149
+ resolve({ ok: false, info: null });
150
+ }
151
+ });
152
+
153
+ if (!result.ok) return false; // errored — retry next launch
154
+
155
+ const referrerQuery = result.info?.installReferrer;
156
+ if (!referrerQuery) {
157
+ // Organic install (or untracked). API answered definitively — mark
158
+ // resolved so we don't ask Play again on every launch.
159
+ logger.debug("InstallReferrer: no Play referrer (organic install)");
160
+ return true;
161
+ }
162
+
163
+ // The referrer string is already URL-encoded UTM params, e.g.
164
+ // "utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_term=kw&utm_content=ad1"
165
+ // Wrap in a dummy URL so parseTrafficSource can read it.
166
+ const parsed = parseTrafficSource(
167
+ `https://play.google.com/store/apps?${referrerQuery}`,
168
+ options.customRefParams,
169
+ options.pathPattern
170
+ );
171
+
172
+ // Don't let the dummy play.google.com URL overwrite a real referrer — clear
173
+ // referrer if it's the synthetic one and no deep link was present.
174
+ const toMerge: Partial<ITrafficSource> = { ...parsed };
175
+ if (
176
+ toMerge.referrer &&
177
+ toMerge.referrer.startsWith("https://play.google.com/store/apps?")
178
+ ) {
179
+ // Preserve the raw referrer query as-is, useful for debugging campaigns
180
+ // that use non-UTM keys.
181
+ toMerge.referrer = referrerQuery;
182
+ }
183
+
184
+ mergeTrafficSourceFill(toMerge);
185
+ logger.info("InstallReferrer: captured Android install referrer");
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * iOS: fetch AdServices attribution token and exchange it with Apple for
191
+ * campaign metadata. Map campaignId/adGroupId/keywordId onto utm_* fields.
192
+ * Falls back to no-op if the native module isn't present.
193
+ */
194
+ async function captureIOSAttribution(): Promise<boolean> {
195
+ if (!AdServicesAttribution) {
196
+ logger.debug(
197
+ "InstallReferrer: react-native-ad-services-attribution not installed, skipping iOS capture"
198
+ );
199
+ return false;
200
+ }
201
+
202
+ let token: string | null;
203
+ try {
204
+ token = await AdServicesAttribution.getAttributionToken();
205
+ } catch (e) {
206
+ logger.debug("InstallReferrer: failed to get AdServices token", e);
207
+ return false;
208
+ }
209
+ if (!token) return false;
210
+
211
+ let data: Record<string, unknown> | null = null;
212
+ // Guard against poor-network hangs — this runs fire-and-forget during init.
213
+ const controller = new AbortController();
214
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
215
+ try {
216
+ const response = await fetch("https://api-adservices.apple.com/api/v1/", {
217
+ method: "POST",
218
+ headers: { "Content-Type": "text/plain" },
219
+ body: token,
220
+ signal: controller.signal,
221
+ });
222
+ if (!response.ok) {
223
+ // 4xx are permanent (400 = bad/consumed token, 404 = past Apple's ~24h
224
+ // attribution window). Mark resolved so we stop retrying. 5xx and
225
+ // other transient failures return false so we re-attempt next launch.
226
+ const permanent = response.status >= 400 && response.status < 500;
227
+ logger.debug(
228
+ `InstallReferrer: AdServices returned ${response.status}, ${
229
+ permanent ? "permanent — marking resolved" : "transient — will retry"
230
+ }`
231
+ );
232
+ return permanent;
233
+ }
234
+ data = (await response.json()) as Record<string, unknown>;
235
+ } catch (e) {
236
+ // Network / abort / parse errors — treat as transient.
237
+ logger.debug("InstallReferrer: AdServices exchange failed", e);
238
+ return false;
239
+ } finally {
240
+ clearTimeout(timeoutId);
241
+ }
242
+
243
+ if (!data || data.attribution === false) {
244
+ // Organic install
245
+ return true;
246
+ }
247
+
248
+ const toStr = (v: unknown): string | undefined =>
249
+ v === undefined || v === null ? undefined : String(v);
250
+
251
+ const attributed: Partial<ITrafficSource> = {
252
+ utm_source: "apple_search_ads",
253
+ utm_medium: "cpc",
254
+ ...(toStr(data.campaignId) && { utm_campaign: toStr(data.campaignId)! }),
255
+ ...(toStr(data.adGroupId) && { utm_content: toStr(data.adGroupId)! }),
256
+ ...(toStr(data.keywordId) && { utm_term: toStr(data.keywordId)! }),
257
+ };
258
+
259
+ mergeTrafficSourceFill(attributed);
260
+ logger.info("InstallReferrer: captured iOS AdServices attribution");
261
+ return true;
262
+ }
@@ -33,7 +33,6 @@ interface IFormoAnalyticsInstance {
33
33
  chainId: number;
34
34
  address: string;
35
35
  message: string;
36
- signatureHash?: string;
37
36
  }): Promise<void>;
38
37
  transaction(params: {
39
38
  status: TransactionStatus;
@@ -362,13 +361,11 @@ export class WagmiEventHandler {
362
361
 
363
362
  try {
364
363
  let status: SignatureStatus;
365
- let signatureHash: string | undefined;
366
364
 
367
365
  if (state.status === "pending") {
368
366
  status = SignatureStatus.REQUESTED;
369
367
  } else if (state.status === "success") {
370
368
  status = SignatureStatus.CONFIRMED;
371
- signatureHash = state.data as string;
372
369
  } else if (state.status === "error") {
373
370
  status = SignatureStatus.REJECTED;
374
371
  } else {
@@ -394,7 +391,6 @@ export class WagmiEventHandler {
394
391
  chainId,
395
392
  address,
396
393
  message,
397
- ...(signatureHash && { signatureHash }),
398
394
  }).catch((error) => {
399
395
  logger.error("WagmiEventHandler: Error tracking signature:", error);
400
396
  });