@formo/analytics 1.33.1 → 1.34.1

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 (45) hide show
  1. package/README.md +3 -0
  2. package/dist/cjs/src/FormoAnalytics.d.ts +65 -19
  3. package/dist/cjs/src/FormoAnalytics.js +188 -94
  4. package/dist/cjs/src/event/EventFactory.d.ts +1 -1
  5. package/dist/cjs/src/event/EventFactory.js +32 -16
  6. package/dist/cjs/src/event/sanitize.d.ts +13 -0
  7. package/dist/cjs/src/event/sanitize.js +94 -0
  8. package/dist/cjs/src/privy/index.d.ts +8 -2
  9. package/dist/cjs/src/privy/index.js +8 -2
  10. package/dist/cjs/src/privy/types.d.ts +25 -2
  11. package/dist/cjs/src/privy/utils.d.ts +100 -0
  12. package/dist/cjs/src/privy/utils.js +375 -16
  13. package/dist/cjs/src/session/index.d.ts +73 -6
  14. package/dist/cjs/src/session/index.js +309 -12
  15. package/dist/cjs/src/solana/SolanaManager.d.ts +1 -1
  16. package/dist/cjs/src/solana/SolanaManager.js +1 -1
  17. package/dist/cjs/src/solana/storeTypes.d.ts +1 -1
  18. package/dist/cjs/src/solana/storeTypes.js +1 -1
  19. package/dist/cjs/src/solana/types.d.ts +2 -2
  20. package/dist/cjs/src/types/base.d.ts +17 -1
  21. package/dist/cjs/src/version.d.ts +1 -1
  22. package/dist/cjs/src/version.js +1 -1
  23. package/dist/esm/src/FormoAnalytics.d.ts +65 -19
  24. package/dist/esm/src/FormoAnalytics.js +188 -94
  25. package/dist/esm/src/event/EventFactory.d.ts +1 -1
  26. package/dist/esm/src/event/EventFactory.js +32 -16
  27. package/dist/esm/src/event/sanitize.d.ts +13 -0
  28. package/dist/esm/src/event/sanitize.js +88 -0
  29. package/dist/esm/src/privy/index.d.ts +8 -2
  30. package/dist/esm/src/privy/index.js +8 -2
  31. package/dist/esm/src/privy/types.d.ts +25 -2
  32. package/dist/esm/src/privy/utils.d.ts +100 -0
  33. package/dist/esm/src/privy/utils.js +374 -16
  34. package/dist/esm/src/session/index.d.ts +73 -6
  35. package/dist/esm/src/session/index.js +309 -12
  36. package/dist/esm/src/solana/SolanaManager.d.ts +1 -1
  37. package/dist/esm/src/solana/SolanaManager.js +1 -1
  38. package/dist/esm/src/solana/storeTypes.d.ts +1 -1
  39. package/dist/esm/src/solana/storeTypes.js +1 -1
  40. package/dist/esm/src/solana/types.d.ts +2 -2
  41. package/dist/esm/src/types/base.d.ts +17 -1
  42. package/dist/esm/src/version.d.ts +1 -1
  43. package/dist/esm/src/version.js +1 -1
  44. package/dist/index.umd.min.js +1 -1
  45. package/package.json +7 -7
@@ -2,14 +2,134 @@
2
2
  /**
3
3
  * Utility functions for extracting profile properties from Privy user objects.
4
4
  */
5
+ var __assign = (this && this.__assign) || function () {
6
+ __assign = Object.assign || function(t) {
7
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
8
+ s = arguments[i];
9
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
10
+ t[p] = s[p];
11
+ }
12
+ return t;
13
+ };
14
+ return __assign.apply(this, arguments);
15
+ };
16
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
17
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
18
+ return new (P || (P = Promise))(function (resolve, reject) {
19
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
20
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
21
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
22
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
23
+ });
24
+ };
25
+ var __generator = (this && this.__generator) || function (thisArg, body) {
26
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
27
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
28
+ function verb(n) { return function (v) { return step([n, v]); }; }
29
+ function step(op) {
30
+ if (f) throw new TypeError("Generator is already executing.");
31
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
32
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
33
+ if (y = 0, t) op = [op[0] & 2, t.value];
34
+ switch (op[0]) {
35
+ case 0: case 1: t = op; break;
36
+ case 4: _.label++; return { value: op[1], done: false };
37
+ case 5: _.label++; y = op[1]; op = [0]; continue;
38
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
39
+ default:
40
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
41
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
42
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
43
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
44
+ if (t[2]) _.ops.pop();
45
+ _.trys.pop(); continue;
46
+ }
47
+ op = body.call(thisArg, _);
48
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
49
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
50
+ }
51
+ };
52
+ var __rest = (this && this.__rest) || function (s, e) {
53
+ var t = {};
54
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
55
+ t[p] = s[p];
56
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
57
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
58
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
59
+ t[p[i]] = s[p[i]];
60
+ }
61
+ return t;
62
+ };
63
+ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
64
+ if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
65
+ if (ar || !(i in from)) {
66
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
67
+ ar[i] = from[i];
68
+ }
69
+ }
70
+ return to.concat(ar || Array.prototype.slice.call(from));
71
+ };
5
72
  Object.defineProperty(exports, "__esModule", { value: true });
6
73
  exports.parsePrivyProperties = parsePrivyProperties;
74
+ exports.identifyPrivyUser = identifyPrivyUser;
75
+ var logger_1 = require("../logger");
76
+ /**
77
+ * Whether a Privy linked account is a usable wallet - an EVM/Solana wallet or
78
+ * smart wallet with an address.
79
+ */
80
+ function isPrivyWalletAccount(account) {
81
+ return ((account.type === "wallet" || account.type === "smart_wallet") &&
82
+ !!account.address);
83
+ }
84
+ /**
85
+ * Coerce a Privy timestamp to epoch milliseconds, tolerating the forms a
86
+ * `PrivyUser` can actually arrive in: a `Date` (React SDK), an ISO string or
87
+ * epoch number (REST API / JSON round-trip). Returns undefined for anything
88
+ * unparseable rather than throwing or emitting NaN.
89
+ */
90
+ function toEpochMillis(value) {
91
+ if (value instanceof Date) {
92
+ var time = value.getTime();
93
+ return Number.isNaN(time) ? undefined : time;
94
+ }
95
+ if (typeof value === "number") {
96
+ // Privy's REST API reports seconds; a Date-derived value is milliseconds.
97
+ // Anything below this threshold is far too old to be a millisecond
98
+ // timestamp, so treat it as seconds.
99
+ if (!Number.isFinite(value))
100
+ return undefined;
101
+ return value < 1e11 ? value * 1000 : value;
102
+ }
103
+ if (typeof value === "string") {
104
+ var parsed = Date.parse(value);
105
+ return Number.isNaN(parsed) ? undefined : parsed;
106
+ }
107
+ return undefined;
108
+ }
109
+ /** A 0x-prefixed 20-byte hex string (prefix and hex are case-insensitive). */
110
+ var EVM_ADDRESS_RE = /^0x[0-9a-f]{40}$/i;
111
+ /**
112
+ * Compare two wallet addresses for equality. EVM addresses are hex and
113
+ * case-insensitive, so they are folded to lowercase; all other chains (notably
114
+ * Solana, whose Base58 addresses are case-sensitive) are compared exactly, so a
115
+ * case difference never matches the wrong wallet.
116
+ */
117
+ function sameAddress(a, b) {
118
+ return EVM_ADDRESS_RE.test(a) && EVM_ADDRESS_RE.test(b)
119
+ ? a.toLowerCase() === b.toLowerCase()
120
+ : a === b;
121
+ }
7
122
  /**
8
123
  * Extract profile properties and wallet addresses from a Privy user object.
9
124
  *
10
125
  * Parses the Privy user's linked accounts into a flat properties object
11
126
  * (email, social accounts, etc.) and extracts all linked wallet addresses.
12
127
  *
128
+ * For most apps prefer the {@link identifyPrivyUser} one-liner, which builds on
129
+ * this function and also forwards per-wallet metadata and handles event
130
+ * attribution. Use `parsePrivyProperties` directly only for advanced/custom
131
+ * flows.
132
+ *
13
133
  * @param user - The Privy user object from `usePrivy()`
14
134
  * @returns An object with `properties` and `wallets`
15
135
  *
@@ -28,17 +148,28 @@ exports.parsePrivyProperties = parsePrivyProperties;
28
148
  * ```
29
149
  */
30
150
  function parsePrivyProperties(user) {
31
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
151
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
32
152
  var accounts = user.linkedAccounts || [];
33
- // Extract profile properties
153
+ // Extract profile properties.
154
+ //
155
+ // Privy's React SDK types `createdAt` as a Date, but PrivyUser is a
156
+ // structural interface - a user object from the REST API, or one that made a
157
+ // JSON round-trip through a cache, carries a string or epoch number instead.
158
+ // Calling .getTime() on those throws, and identify()'s outer catch would
159
+ // swallow it, so the whole one-liner would silently emit nothing. Normalize
160
+ // instead of trusting the declared type.
34
161
  var properties = {
35
162
  privyDid: user.id,
36
- privyCreatedAt: (_a = user.createdAt) === null || _a === void 0 ? void 0 : _a.getTime(),
163
+ privyCreatedAt: toEpochMillis(user.createdAt),
37
164
  };
38
165
  // Email
39
- if ((_b = user.email) === null || _b === void 0 ? void 0 : _b.address) {
166
+ if ((_a = user.email) === null || _a === void 0 ? void 0 : _a.address) {
40
167
  properties.email = user.email.address;
41
168
  }
169
+ // Phone
170
+ if ((_b = user.phone) === null || _b === void 0 ? void 0 : _b.number) {
171
+ properties.phone = user.phone.number;
172
+ }
42
173
  // Social accounts - extract usernames/identifiers
43
174
  if ((_c = user.apple) === null || _c === void 0 ? void 0 : _c.email) {
44
175
  properties.apple = user.apple.email;
@@ -73,7 +204,10 @@ function parsePrivyProperties(user) {
73
204
  if ((_o = user.tiktok) === null || _o === void 0 ? void 0 : _o.username) {
74
205
  properties.tiktok = user.tiktok.username;
75
206
  }
76
- if ((_p = user.twitter) === null || _p === void 0 ? void 0 : _p.username) {
207
+ if ((_p = user.twitch) === null || _p === void 0 ? void 0 : _p.username) {
208
+ properties.twitch = user.twitch.username;
209
+ }
210
+ if ((_q = user.twitter) === null || _q === void 0 ? void 0 : _q.username) {
77
211
  properties.twitter = user.twitter.username;
78
212
  }
79
213
  // Fallback to linkedAccounts if convenience accessors are not populated
@@ -83,6 +217,13 @@ function parsePrivyProperties(user) {
83
217
  properties.email = emailAccount.address;
84
218
  }
85
219
  }
220
+ if (!properties.phone) {
221
+ // A phone account carries the value in `number`, not `address`.
222
+ var phoneAccount = accounts.find(function (a) { return a.type === "phone"; });
223
+ if (phoneAccount === null || phoneAccount === void 0 ? void 0 : phoneAccount.number) {
224
+ properties.phone = phoneAccount.number;
225
+ }
226
+ }
86
227
  if (!properties.apple) {
87
228
  var appleAccount = accounts.find(function (a) { return a.type === "apple_oauth"; });
88
229
  if (appleAccount === null || appleAccount === void 0 ? void 0 : appleAccount.email) {
@@ -155,12 +296,26 @@ function parsePrivyProperties(user) {
155
296
  properties.tiktok = tiktokAccount.username;
156
297
  }
157
298
  }
299
+ if (!properties.twitch) {
300
+ var twitchAccount = accounts.find(function (a) { return a.type === "twitch_oauth"; });
301
+ if (twitchAccount === null || twitchAccount === void 0 ? void 0 : twitchAccount.username) {
302
+ properties.twitch = twitchAccount.username;
303
+ }
304
+ else if (twitchAccount === null || twitchAccount === void 0 ? void 0 : twitchAccount.email) {
305
+ properties.twitch = twitchAccount.email;
306
+ }
307
+ }
158
308
  if (!properties.twitter) {
159
309
  var twitterAccount = accounts.find(function (a) { return a.type === "twitter_oauth"; });
160
310
  if (twitterAccount === null || twitterAccount === void 0 ? void 0 : twitterAccount.username) {
161
311
  properties.twitter = twitterAccount.username;
162
312
  }
163
313
  }
314
+ // Custom auth: the app's own user id for this Privy user.
315
+ var customAuthAccount = accounts.find(function (a) { return a.type === "custom_auth"; });
316
+ if (customAuthAccount === null || customAuthAccount === void 0 ? void 0 : customAuthAccount.customUserId) {
317
+ properties.customUserId = customAuthAccount.customUserId;
318
+ }
164
319
  // Use OAuth emails as fallback for email if still blank
165
320
  // Priority: email -> google -> apple -> linkedin
166
321
  if (!properties.email) {
@@ -174,18 +329,222 @@ function parsePrivyProperties(user) {
174
329
  properties.email = properties.linkedin;
175
330
  }
176
331
  }
177
- // Extract wallet addresses
178
- var wallets = accounts
179
- .filter(function (a) { return (a.type === "wallet" || a.type === "smart_wallet") && a.address; })
180
- .map(function (a) {
181
- var _a, _b;
182
- return ({
183
- address: a.address,
184
- walletClient: (_a = (a.walletClientType || a.walletClient)) !== null && _a !== void 0 ? _a : undefined,
185
- chainType: (_b = a.chainType) !== null && _b !== void 0 ? _b : undefined,
186
- isEmbedded: a.walletClientType === "privy" || a.walletClient === "privy",
332
+ // Extract wallet addresses. `fromCrossApp` is internal bookkeeping for the
333
+ // dedup below and is stripped from the returned wallets.
334
+ var wallets = [];
335
+ for (var _i = 0, accounts_1 = accounts; _i < accounts_1.length; _i++) {
336
+ var account = accounts_1[_i];
337
+ if (isPrivyWalletAccount(account)) {
338
+ wallets.push({
339
+ address: account.address,
340
+ walletClient: (_r = (account.walletClientType || account.walletClient)) !== null && _r !== void 0 ? _r : undefined,
341
+ chainType: (_s = account.chainType) !== null && _s !== void 0 ? _s : undefined,
342
+ isEmbedded: account.walletClientType === "privy" ||
343
+ account.walletClient === "privy",
344
+ });
345
+ continue;
346
+ }
347
+ // A cross_app account (e.g. Abstract Global Wallet) has no top-level
348
+ // address - its wallets live in `embeddedWallets`/`smartWallets`. They are
349
+ // still wallets the user owns, so identify them for clustering too.
350
+ if (account.type === "cross_app") {
351
+ var crossAppWallets = __spreadArray(__spreadArray([], (account.embeddedWallets || []), true), (account.smartWallets || []), true);
352
+ for (var _u = 0, crossAppWallets_1 = crossAppWallets; _u < crossAppWallets_1.length; _u++) {
353
+ var w = crossAppWallets_1[_u];
354
+ if (!(w === null || w === void 0 ? void 0 : w.address))
355
+ continue;
356
+ wallets.push({
357
+ address: w.address,
358
+ walletClient: "cross_app",
359
+ chainType: (_t = w.chainType) !== null && _t !== void 0 ? _t : undefined,
360
+ isEmbedded: true,
361
+ fromCrossApp: true,
362
+ });
363
+ }
364
+ }
365
+ }
366
+ // Deduplicate: the same address can appear as both a `wallet` and a
367
+ // `smart_wallet`/cross-app entry, and identifying it twice would emit a
368
+ // redundant event (and, for the active wallet, a redundant promotion).
369
+ //
370
+ // Which duplicate wins is not just first-come: a cross_app entry carries only
371
+ // an address (so it's recorded as `cross_app`/embedded by default), while a
372
+ // top-level wallet entry carries the real walletClient, chainType, and
373
+ // embedded classification. Prefer the richer entry regardless of the order
374
+ // Privy happened to list the accounts in, so the metadata - and the
375
+ // last-external active-wallet fallback, which keys on isEmbedded - don't
376
+ // depend on account ordering.
377
+ var indexByAddress = new Map();
378
+ var deduped = [];
379
+ for (var _v = 0, wallets_1 = wallets; _v < wallets_1.length; _v++) {
380
+ var wallet = wallets_1[_v];
381
+ var key = EVM_ADDRESS_RE.test(wallet.address)
382
+ ? wallet.address.toLowerCase()
383
+ : wallet.address;
384
+ var existingIndex = indexByAddress.get(key);
385
+ if (existingIndex === undefined) {
386
+ indexByAddress.set(key, deduped.length);
387
+ deduped.push(wallet);
388
+ continue;
389
+ }
390
+ // Only upgrade a placeholder cross-app entry to a real wallet entry; never
391
+ // downgrade, and never let a second cross-app entry replace anything. The
392
+ // position is kept so wallet order stays stable.
393
+ if (!wallet.fromCrossApp && deduped[existingIndex].fromCrossApp) {
394
+ deduped[existingIndex] = wallet;
395
+ }
396
+ }
397
+ var uniqueWallets = deduped.map(function (_a) {
398
+ var _fromCrossApp = _a.fromCrossApp, info = __rest(_a, ["fromCrossApp"]);
399
+ return info;
400
+ });
401
+ return { properties: properties, wallets: uniqueWallets };
402
+ }
403
+ /**
404
+ * Identify every wallet linked to a Privy user under that user's Privy DID.
405
+ *
406
+ * This is the one-liner replacement for hand-rolling a loop over
407
+ * {@link parsePrivyProperties}. For each linked wallet it calls
408
+ * `analytics.identify({ address, userId: user.id }, …)` with the shared
409
+ * profile properties plus that wallet's `wallet_client`, `chain_type`, and
410
+ * `is_embedded` metadata. Because every wallet is tagged with the same Privy
411
+ * `userId`, Formo can cluster them server-side into a single user.
412
+ *
413
+ * Attribution: only the active wallet (see
414
+ * {@link IdentifyPrivyUserOptions.activeAddress}) promotes the SDK's current
415
+ * address/user; every other linked wallet is recorded purely for clustering and
416
+ * does not repoint attribution. Because the clustering identifies don't touch
417
+ * active state, a connected wallet that isn't linked in Privy is left untouched
418
+ * rather than overwritten. This does not change the public `identify()` API.
419
+ *
420
+ * Before emitting, it reconciles the SDK's chain id with the active wallet's
421
+ * chain namespace (clearing a stale EVM chain id when a Solana wallet becomes
422
+ * active, and vice versa), so identifies aren't dropped by an `excludeChains`
423
+ * gate and later events aren't paired with the wrong chain. This happens here,
424
+ * so the direct helper and the `formo.identify(user)` form
425
+ * behave identically.
426
+ *
427
+ * Returns the active linked wallet's `{ address, chainType }` (the one now
428
+ * owning attribution), or `undefined` when no linked wallet matched the
429
+ * requested active address (or the user had no wallets).
430
+ *
431
+ * All linked wallet addresses used here come from `user.linkedAccounts`, which
432
+ * is fully available on the frontend from Privy's `usePrivy()` hook.
433
+ *
434
+ * Note: `identify()` is keyed on a wallet address, so a Privy user with no
435
+ * linked wallet is a no-op (nothing is emitted). Attaching a user identity that
436
+ * has no wallet is out of scope for this address-keyed helper.
437
+ *
438
+ * @param analytics - The Formo analytics instance (e.g. from `useFormo()`)
439
+ * @param user - The Privy user object from `usePrivy()`
440
+ * @param options - See {@link IdentifyPrivyUserOptions}
441
+ *
442
+ * @example
443
+ * ```ts
444
+ * import { identifyPrivyUser } from '@formo/analytics';
445
+ *
446
+ * const { user } = usePrivy();
447
+ * const { wallets } = useWallets();
448
+ * if (user) {
449
+ * // activeAddress is optional - omit it if the SDK already tracks the
450
+ * // connected wallet via a wagmi/EIP-1193 connect.
451
+ * await identifyPrivyUser(formo, user, {
452
+ * activeAddress: wallets[0]?.address,
453
+ * });
454
+ * }
455
+ * ```
456
+ */
457
+ function identifyPrivyUser(analytics_1, user_1) {
458
+ return __awaiter(this, arguments, void 0, function (analytics, user, options) {
459
+ var target, _a, properties, wallets, baseProperties, activeWallet, identify, _i, wallets_2, wallet, walletProperties;
460
+ var _b, _c, _d, _e;
461
+ if (options === void 0) { options = {}; }
462
+ return __generator(this, function (_f) {
463
+ switch (_f.label) {
464
+ case 0:
465
+ if (!analytics || !user)
466
+ return [2 /*return*/, undefined];
467
+ target = analytics;
468
+ // If tracking is suppressed for this visitor/route (opt-out / timezone / host /
469
+ // path), do nothing. The inner identify() calls would each be gated anyway,
470
+ // but the chain reconciliation below runs BEFORE them - so without this guard
471
+ // it would clear an excluded chain id while no identify actually happens,
472
+ // leaving later events on an allowed route unable to apply `excludeChains`.
473
+ if ((_b = target.isTrackingSuppressed) === null || _b === void 0 ? void 0 : _b.call(target))
474
+ return [2 /*return*/, undefined];
475
+ _a = parsePrivyProperties(user), properties = _a.properties, wallets = _a.wallets;
476
+ // identify() is keyed on a wallet address, so with no linked wallets there is
477
+ // nothing to attach the Privy identity to. Log it so a walletless user (or a
478
+ // pre-wallet account-creation flow) doesn't silently disappear.
479
+ if (wallets.length === 0) {
480
+ logger_1.logger.info("identifyPrivyUser: user has no linked wallets; nothing to identify", user.id);
481
+ return [2 /*return*/, undefined];
482
+ }
483
+ baseProperties = __assign(__assign({}, properties), options.properties);
484
+ activeWallet = resolveActiveWallet(wallets, (_c = options.activeAddress) !== null && _c !== void 0 ? _c : target.currentAddress, (_d = user.wallet) === null || _d === void 0 ? void 0 : _d.address);
485
+ // Reconcile the chain BEFORE emitting any identify. identify() runs each event
486
+ // through the tracking gate (which enforces `excludeChains` against the
487
+ // current chain id); if the active wallet is on a different chain namespace
488
+ // than the stale current chain id (e.g. a Solana wallet while an EVM chain was
489
+ // current, and that EVM chain is excluded), reconciling first prevents the
490
+ // clustering identifies from being silently dropped. Doing it here - rather
491
+ // than in the identify(user,{privy:true}) dispatch - means the direct
492
+ // identifyPrivyUser() entry point gets the same treatment.
493
+ // Pass the address too: Privy omits chainType on smart_wallet and cross_app
494
+ // entries, and a 0x address is enough to identify the EVM namespace.
495
+ (_e = target.syncPrivyActiveChain) === null || _e === void 0 ? void 0 : _e.call(target, activeWallet === null || activeWallet === void 0 ? void 0 : activeWallet.chainType, activeWallet === null || activeWallet === void 0 ? void 0 : activeWallet.address);
496
+ identify = target.identify.bind(analytics);
497
+ _i = 0, wallets_2 = wallets;
498
+ _f.label = 1;
499
+ case 1:
500
+ if (!(_i < wallets_2.length)) return [3 /*break*/, 4];
501
+ wallet = wallets_2[_i];
502
+ walletProperties = __assign(__assign({}, baseProperties), { is_embedded: wallet.isEmbedded });
503
+ if (wallet.walletClient)
504
+ walletProperties.wallet_client = wallet.walletClient;
505
+ if (wallet.chainType)
506
+ walletProperties.chain_type = wallet.chainType;
507
+ return [4 /*yield*/, identify({
508
+ address: wallet.address,
509
+ userId: user.id,
510
+ setActive: wallet === activeWallet,
511
+ }, walletProperties)];
512
+ case 2:
513
+ _f.sent();
514
+ _f.label = 3;
515
+ case 3:
516
+ _i++;
517
+ return [3 /*break*/, 1];
518
+ case 4: return [2 /*return*/, activeWallet
519
+ ? { address: activeWallet.address, chainType: activeWallet.chainType }
520
+ : undefined];
521
+ }
187
522
  });
188
523
  });
189
- return { properties: properties, wallets: wallets };
524
+ }
525
+ /**
526
+ * Choose the linked wallet that should own event attribution.
527
+ *
528
+ * An explicit `activeAddress` (a caller override, or the SDK's already-connected
529
+ * wallet) is matched strictly and never falls back: a connected wallet that is
530
+ * not in `linkedAccounts` resolves to `undefined`, so the sync leaves the
531
+ * current wallet untouched. With no active address, prefer Privy's surfaced
532
+ * primary (`user.wallet`), then a best-effort guess (embedded wallets first, so
533
+ * the last external wallet). Address comparison is chain-appropriate so Solana
534
+ * casing isn't mismatched.
535
+ */
536
+ function resolveActiveWallet(wallets, activeAddress, primaryAddress) {
537
+ if (activeAddress) {
538
+ return wallets.find(function (w) { return sameAddress(w.address, activeAddress); });
539
+ }
540
+ if (primaryAddress) {
541
+ var primary = wallets.find(function (w) { return sameAddress(w.address, primaryAddress); });
542
+ if (primary)
543
+ return primary;
544
+ }
545
+ var external = wallets.filter(function (w) { return !w.isEmbedded; });
546
+ return external.length > 0
547
+ ? external[external.length - 1]
548
+ : wallets[wallets.length - 1];
190
549
  }
191
550
  //# sourceMappingURL=utils.js.map
@@ -4,6 +4,7 @@
4
4
  * Handles tracking of detected wallets and identified wallet-address pairs
5
5
  * using cookies to maintain state across page loads within a session.
6
6
  */
7
+ import type { IFormoEventProperties } from "../types/events";
7
8
  /**
8
9
  * Cookie keys for session tracking
9
10
  * NOTE: These values must match the original constants in constants/base.ts
@@ -29,25 +30,91 @@ export interface IFormoAnalyticsSession {
29
30
  * Check if a wallet-address pair has been identified in this session
30
31
  * @param address The wallet address
31
32
  * @param rdns The reverse domain name (RDNS) of the wallet provider
33
+ * @param userId Optional external user ID (e.g. a Privy DID). When provided,
34
+ * it is folded into the dedup key so attaching a new user ID to an
35
+ * already-identified wallet re-emits instead of being silently deduped.
36
+ * @param properties Optional identify properties. Fingerprinted into the
37
+ * dedup key so an identify whose properties have *changed* (e.g. a Privy
38
+ * user linked a new social account) re-emits, while an unchanged repeat
39
+ * still dedupes.
32
40
  */
33
- isWalletIdentified(address: string, rdns: string): boolean;
41
+ isWalletIdentified(address: string, rdns: string, userId?: string, properties?: IFormoEventProperties): boolean;
34
42
  /**
35
43
  * Mark a wallet-address pair as identified in this session
36
44
  * @param address The wallet address
37
45
  * @param rdns The reverse domain name (RDNS) of the wallet provider
46
+ * @param userId Optional external user ID (e.g. a Privy DID). See
47
+ * {@link isWalletIdentified} for how it affects the dedup key.
48
+ * @param properties Optional identify properties. See
49
+ * {@link isWalletIdentified}.
38
50
  */
39
- markWalletIdentified(address: string, rdns: string): void;
51
+ markWalletIdentified(address: string, rdns: string, userId?: string, properties?: IFormoEventProperties): void;
40
52
  }
41
53
  export declare class FormoAnalyticsSession implements IFormoAnalyticsSession {
42
54
  /**
43
- * Generate a unique key for wallet identification tracking
44
- * Combines address and RDNS to track specific wallet-address combinations
55
+ * Generate a unique key for wallet identification tracking.
56
+ *
57
+ * Combines address, RDNS, and (optionally) the external user ID and a
58
+ * fingerprint of the identify's properties, so the key identifies a specific
59
+ * wallet-user-profile combination rather than just an address.
60
+ *
61
+ * Folding the user ID in means the same wallet identified first anonymously
62
+ * and later with a user ID (e.g. after a Privy login attaches a DID) produces
63
+ * two distinct keys, so the second identify is not deduped.
64
+ *
65
+ * Folding the properties hash in means a *changed* profile re-emits. This
66
+ * matters for account linking: a Privy user who links a Google account keeps
67
+ * the same wallets and the same DID, so without the hash every already-seen
68
+ * wallet would dedupe and the new `google` property would never reach Formo
69
+ * until the session expired. An identify repeated with identical properties
70
+ * still dedupes, so this does not turn a re-render into an event.
71
+ *
72
+ * Key shapes, by component count - each is unambiguous, so they cannot
73
+ * collide with one another:
74
+ *
75
+ * | Components | Shape | When |
76
+ * | --- | --- | --- |
77
+ * | 1 | `address` | no rdns, no userId, no properties |
78
+ * | 2 | `address:rdns` | rdns only |
79
+ * | 3 | `address:rdns:userId` | userId, no properties |
80
+ * | 4 | `address:rdns:userId:hash` | properties present |
81
+ *
82
+ * Shapes 1 and 2 are unchanged from before user IDs and property hashes
83
+ * existed, so keys already stored in browsers still match (backward
84
+ * compatible). An identify that carries properties moves to shape 4, so the
85
+ * first identify after an upgrade re-emits once per wallet - a one-off, and
86
+ * the correct outcome, since those properties were never recorded under the
87
+ * new key.
45
88
  *
46
89
  * @param address The wallet address
47
90
  * @param rdns The reverse domain name of the wallet provider
91
+ * @param userId Optional external user ID (e.g. a Privy DID)
92
+ * @param properties Optional identify properties, fingerprinted into the key
48
93
  * @returns A unique identification key
49
94
  */
50
95
  private generateIdentificationKey;
96
+ /**
97
+ * Build the dedup key plus the **identity prefix** it belongs to.
98
+ *
99
+ * The identity prefix is the key with the properties hash stripped -
100
+ * `address:rdns:userId` - i.e. *which wallet-user this is*, independent of
101
+ * *what profile it last had*. `markWalletIdentified` uses it to drop that
102
+ * identity's previous state before storing the new one, which matters twice:
103
+ *
104
+ * - **Reversion.** Keeping every state seen would make dedup mean "have I
105
+ * ever seen this exact profile", so a profile that goes A → B → A (link
106
+ * then unlink an account) would find the old A key and emit nothing. Dedup
107
+ * should mean "is this the same as this wallet's *last* identify", so only
108
+ * the current state is retained.
109
+ * - **Growth.** Otherwise each profile change adds a key per wallet, and an
110
+ * 8-wallet user linking a few accounts would push the cookie into eviction.
111
+ * Superseding keeps it at one entry per wallet-user.
112
+ *
113
+ * The prefix is only defined for keys that have a userId and/or a properties
114
+ * hash (3+ components). The legacy 1- and 2-component shapes are the whole
115
+ * identity already, so there is nothing to supersede.
116
+ */
117
+ private buildIdentificationKey;
51
118
  /**
52
119
  * Check if a wallet provider has been detected in this session
53
120
  *
@@ -69,7 +136,7 @@ export declare class FormoAnalyticsSession implements IFormoAnalyticsSession {
69
136
  * @param rdns The reverse domain name of the wallet provider
70
137
  * @returns true if this wallet-address pair has been identified
71
138
  */
72
- isWalletIdentified(address: string, rdns: string): boolean;
139
+ isWalletIdentified(address: string, rdns: string, userId?: string, properties?: IFormoEventProperties): boolean;
73
140
  /**
74
141
  * Mark a wallet-address combination as identified in this session
75
142
  * Prevents duplicate identification events from being emitted
@@ -77,6 +144,6 @@ export declare class FormoAnalyticsSession implements IFormoAnalyticsSession {
77
144
  * @param address The wallet address
78
145
  * @param rdns The reverse domain name of the wallet provider
79
146
  */
80
- markWalletIdentified(address: string, rdns: string): void;
147
+ markWalletIdentified(address: string, rdns: string, userId?: string, properties?: IFormoEventProperties): void;
81
148
  }
82
149
  //# sourceMappingURL=index.d.ts.map