diff --git a/README.md b/README.md index 3d28f55..bd772b1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ You can install Formo on: Visit Formo's [Developer Docs](https://docs.formo.so) for detailed guides on local testing, debugging, and consent management. +## Integrations + +In-depth guides for specific stacks live in [`docs/`](./docs): + +- [Wagmi](./docs/WAGMI_INTEGRATION.md) · [Wagmi + Next.js](./docs/WAGMI_NEXTJS_SETUP.md) +- [Solana](./docs/SOLANA_INTEGRATION.md) +- [Privy](./docs/PRIVY_INTEGRATION.md) — one-liner to cluster a Privy user's many wallets under a single identity + ## Methodology Learn how Formo handles [onchain attribution](https://docs.formo.so/data/attribution) and [data collection](https://docs.formo.so/data/what-we-collect). diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md new file mode 100644 index 0000000..99c461b --- /dev/null +++ b/docs/PRIVY_INTEGRATION.md @@ -0,0 +1,278 @@ +# Privy Integration for Formo Analytics SDK + +## Overview + +[Privy](https://privy.io) gives each user a single account (identified by a +Privy DID such as `did:privy:cm3np...`) that can have **many linked wallets** — +an embedded Privy wallet plus any external wallets (MetaMask, Rainbow, Coinbase, +smart wallets, Solana wallets, …) the user connects over time. + +To Formo, each of those wallet addresses looks like a different user. The Privy +integration fixes that: it tags **every** linked wallet with the same Privy +`userId`, so Formo can cluster them server-side into one user. Attach a wallet +today, connect three more next week — they all roll up under the same identity. + +``` + Privy user (did:privy:abc…) + ┌───────────────┬───────────────┬───────────────┐ + embedded 0x11… MetaMask 0x22… Rainbow 0x33… Solana 9xQ… + └───────────────┴───────────────┴───────────────┘ + identify({ address, userId: "did:privy:abc…" }) for each wallet + ↓ + Formo clusters them into one user +``` + +The whole thing is a single `identify(user, { privy: true })` call — a one-line +replacement for hand-rolling an `identify()` loop over the linked wallets. + +## Quick start (React) + +Pass the `usePrivy()` user to `identify()` with `{ privy: true }`. Call it from +an effect that runs when the user changes, so login, `linkWallet`, and +`unlinkWallet` all keep Formo's identity in sync. No separate helper or hook. + +```tsx +import { useEffect } from "react"; +import { usePrivy } from "@privy-io/react-auth"; +import { useFormo } from "@formo/analytics"; + +function AnalyticsIdentity() { + const formo = useFormo(); + const { user, authenticated } = usePrivy(); + + useEffect(() => { + if (formo && authenticated && user) { + formo.identify(user, { privy: true }); + } + }, [formo, authenticated, user]); + + return null; +} +``` + +That single `identify(user, { privy: true })` call identifies **every** wallet +linked to the Privy user under the user's DID, forwards each wallet's metadata, +and pins event attribution to the active wallet. + +> The effect above is yours to own — key it on `user` (and `wallets`) so it +> re-runs on login and link/unlink. The SDK deduplicates the underlying identify +> events per `(wallet, user)`, so re-running on every render is safe. + +## How it works + +`identify(user, { privy: true })` is a thin convenience form of `identify()`: +when it sees the `{ privy: true }` flag it treats the first argument as a Privy +user and expands `user.linkedAccounts`, emitting one identify per linked wallet +under the shared DID. The Privy-specific logic lives in the SDK's Privy module; +the core `identify()` just dispatches to it. + +Only the **active** wallet updates the SDK's current address/user (what later +events are attributed to). The other linked wallets are recorded purely for +clustering and never repoint attribution — so a wallet you've already connected +(even one that isn't linked in Privy) is left alone. When the active wallet is on +a different chain namespace than the current chain id (e.g. a Solana wallet while +an EVM chain was current), the mismatched chain id is cleared so events aren't +paired with the wrong chain. + +## Framework-agnostic usage + +Not using React (or prefer an explicit function)? `identifyPrivyUser` is the +same thing without the flag, and works from the `core` entry too. + +```ts +import { identifyPrivyUser } from "@formo/analytics"; + +const { user } = usePrivy(); +if (user) { + await identifyPrivyUser(formo, user, { + activeAddress: connectedWallet?.address, // optional; see "attribution" below + }); +} +``` + +`formo.identify(user, { privy: true, activeAddress, properties })` and +`identifyPrivyUser(formo, user, { activeAddress, properties })` are equivalent — +the former is sugar over the latter. + +### Signature + +```ts +identifyPrivyUser( + analytics: IFormoAnalytics, + user: PrivyUser, + options?: { + activeAddress?: string; // active/connected wallet + properties?: IFormoEventProperties; // merged into every identify call + } +): Promise +``` + +## What gets sent + +For each linked wallet, `identifyPrivyUser` calls: + +```ts +formo.identify( + { address, userId: user.id }, + { + ...profileProperties, // email, socials, privyDid, privyCreatedAt, … + wallet_client, // e.g. "metamask", "privy", "rainbow" + chain_type, // e.g. "ethereum", "solana" + is_embedded, // true for the Privy embedded wallet + } +); +``` + +Only the active wallet takes over event attribution; the other linked wallets +are recorded purely for clustering and never become the current address (see +[attribution](#event-attribution-and-the-active-wallet) below). + +The shared **profile properties** are parsed from the Privy user's linked +accounts (see [`parsePrivyProperties`](#advanced-parseprivyproperties)) and +include email, connected socials (Twitter/X, Discord, GitHub, Farcaster, +Google, …), the Privy DID, and account creation time. + +The **per-wallet metadata** (`wallet_client`, `chain_type`, `is_embedded`) is +attached per-address, so you can tell an embedded wallet apart from an external +one, and an Ethereum wallet apart from a Solana one, in your analytics. +`wallet_client` and `chain_type` are omitted when Privy doesn't provide them; +`is_embedded` is always present. + +> **`options.properties` are captured at first identify.** Identify events are +> deduped per `(wallet, user)` per session (see [below](#when-identity-re-emits)), +> so extra properties you pass are recorded on the first identify for each wallet +> and are **not** refreshed by later calls in the same session. Treat them as +> identity metadata set at identify time, not a live user profile. To update a +> live trait (plan, org, …), send it on your own events instead. + +## Event attribution and the active wallet + +A Privy user's `linkedAccounts` lists **every** wallet they've ever linked — not +which one they're using right now. `identify()` also updates the SDK's "current +address" (the wallet later events are attributed to), so the sync has to pick +exactly **one** active wallet; the rest are recorded for clustering without +touching attribution. The active wallet is chosen, in order: + +1. **`activeAddress`**, if you pass it (an optional override — e.g. the connected + wallet from `useWallets()[0]?.address` or your wagmi account, which reflects + the live active wallet most precisely). It's matched **strictly**: if the + address you pass isn't one of the linked wallets, the sync promotes *no* + wallet and leaves your current address untouched. +2. else **`user.wallet`** — the primary wallet Privy surfaces on the user object, + so `identify(user, { privy: true })` needs no argument at all; +3. else a best-effort guess: embedded (Privy) wallets deprioritized, so the last + external wallet. + +Because only the active wallet repoints attribution (via an internal flag, not a +public `identify()` option), a wallet you've already connected is never +clobbered by the clustering identifies. In practice you can just call +`formo.identify(user, { privy: true })`: if the SDK already tracks a connected +wallet it's kept; otherwise it falls to Privy's primary. Pass `activeAddress` +only to pin attribution to a specific wallet. + +## When identity re-emits + +Formo deduplicates identify events per session. The dedup key now includes the +`userId`, so: + +- Identifying the same wallet twice with the **same** Privy DID is deduped (no + spam on re-render). +- A wallet that was already identified anonymously (e.g. on connect) **re-emits** + once the Privy DID is attached after login. +- Switching Privy users on the same wallet re-emits under the new DID. + +Combined with re-running your effect on `user` changes, login and `linkWallet` +produce exactly the identify events you'd expect and nothing more. `unlinkWallet` +re-runs the effect for the remaining wallets but emits no event of its own — see +[Limitations](#limitations--roadmap). + +## Advanced: `parsePrivyProperties` + +`identifyPrivyUser` is built on `parsePrivyProperties`, which is still exported +for advanced or custom flows. It parses a Privy user into a flat properties +object and the list of linked wallets, without emitting anything: + +```ts +import { parsePrivyProperties } from "@formo/analytics"; + +const { properties, wallets } = parsePrivyProperties(user); +// properties: { privyDid, email, twitter, github, … } +// wallets: [{ address, walletClient, chainType, isEmbedded }, …] + +for (const wallet of wallets) { + formo.identify( + { address: wallet.address, userId: user.id }, + { ...properties, wallet_client: wallet.walletClient }, + ); +} +``` + +Reach for this only if you need behavior `identifyPrivyUser` doesn't cover — +otherwise prefer the one-liner, which also handles per-wallet metadata and +active-wallet attribution. + +## Limitations & roadmap + +The helper is deliberately scoped to **wallet-keyed identity clustering**. Two +related product concerns are out of scope for it today: + +- **Walletless users.** `identify()` is keyed on a wallet address, so a Privy + user with no linked wallet is a no-op (logged, not emitted). Pre-wallet + account-creation flows and purely social logins therefore won't appear as + users until they have a wallet. Surfacing account identity independent of a + wallet needs a userId-keyed identify on the ingest side — a separate, + backend-coordinated change. +- **Unlink is additive.** `identify(user, { privy: true })` emits positive + wallet↔user link events only. When a wallet is unlinked in Privy your effect + re-runs for the smaller set, but there is no SDK-level "unlink" event, so from + the backend's perspective links only accumulate. Modeling removal needs an + explicit unlink event and server-side handling. + +Both are natural next steps for a users/clustering product surface, not part of +the identify one-liner. + +## The Privy user object + +The integration reads the standard Privy user object returned by +[`usePrivy()`](https://docs.privy.io/guide/react/users/object). The linked +wallet addresses come from `user.linkedAccounts`, which **is fully available on +the frontend** — no server call required. Each wallet entry looks like: + +```ts +{ + type: "wallet", // or "smart_wallet" + address: "0x…", + walletClientType: "privy", // "privy" ⇒ embedded wallet + chainType: "ethereum", // or "solana" + connectorType: "embedded", +} +``` + +`useWallets()` (from `@privy-io/react-auth`) returns only the **currently +connected** wallets, with the active wallet first (`wallets[0]`). That's why the +SDK can't determine the active wallet on its own from `user` alone, and why you +pass `activeAddress` in. + +References: +- [The user object](https://docs.privy.io/guide/react/users/object) +- [Handling multiple wallets](https://docs.privy.io/guide/frontend/wallets/multiwallet) +- [Linking additional accounts](https://docs.privy.io/guide/react/users/linking) + +## FAQ + +**Do I need an `alias()` call to merge wallets?** +No. Because every wallet is identified with the same `userId`, Formo merges them +server-side. There's no separate alias step. + +**What about wallets the user links later?** +Because you call `identify(user, { privy: true })` from an effect keyed on +`user`, it re-runs whenever the linked-wallet set changes, so newly linked +wallets are identified automatically. + +**Does this work for Solana wallets?** +Yes. Solana wallets appear in `linkedAccounts` with `chainType: "solana"` and +are identified the same way; the `chain_type` property is forwarded so you can +segment by chain. + +**Can I add my own properties?** +Yes — pass `options.properties` and they're merged into every identify call. diff --git a/docs/privy-identity-integration-plan.md b/docs/privy-identity-integration-plan.md new file mode 100644 index 0000000..458683b --- /dev/null +++ b/docs/privy-identity-integration-plan.md @@ -0,0 +1,149 @@ +# Privy Identity Integration — Plan & Status + +Status doc for the Privy identity work (PR #304). For usage/how-to, see +[`PRIVY_INTEGRATION.md`](./PRIVY_INTEGRATION.md); this file tracks the design, +what was fixed, and what is intentionally deferred. + +## Problem + +A Privy user is one account (a DID, e.g. `did:privy:cm3np…`) with **many linked +wallets** — an embedded Privy wallet plus any external wallets they connect over +time. To Formo each wallet address looks like a separate user, so an 8‑wallet +Privy user fragments into 8 users. We want them clustered into one. + +## Approach + +Tag **every** linked wallet with the same Privy `userId` via `identify()`. +Because the wallets share a `userId`, Formo's existing clustering merges them +server‑side — no new `alias()` API needed. This is **Phase 1 (SDK)**; the +product surfaces that consume the clustering (a Users tab, etc.) are Phase 2. + +## Phase 1 — status: ✅ complete + +### Public API + +| Symbol | Purpose | +| --- | --- | +| `formo.identify(user, { privy: true, activeAddress?, properties? })` | **Headline.** Identify every linked wallet under the DID in one call. | +| `identifyPrivyUser(analytics, user, options?)` | Framework‑agnostic equivalent (what the flag delegates to). Returns the active wallet's `{ address, chainType }` or `undefined`. | +| `parsePrivyProperties(user)` → `{ properties, wallets }` | Low‑level parse, for custom flows. | +| Types | `PrivyUser`, `PrivyLinkedAccount`, `PrivyAccountType`, `PrivyProfileProperties`, `PrivyWalletInfo`, `IdentifyPrivyUserOptions` | + +Exported from the package root and the React‑free `./core` entry. There is **no +React hook** — apps call `identify(user, { privy: true })` from their own effect +(keyed on the Privy `user`), which covers login / `linkWallet` / `unlinkWallet`. + +### What each wallet sends + +```ts +identify( + { address, userId: user.id }, + { ...profileProperties, wallet_client, chain_type, is_embedded }, +) +``` + +- `profileProperties`: email, socials (Twitter/X, Discord, GitHub, Farcaster, + Google, …), `privyDid`, `privyCreatedAt`, parsed from `linkedAccounts`. +- Per‑wallet metadata: `wallet_client`, `chain_type`, `is_embedded` + (`is_embedded` always present; the others omitted when Privy doesn't provide + them). + +### Gaps closed (vs. the original brief) + +| Gap | Fix | +| --- | --- | +| **2 — per‑wallet metadata dropped** | `wallet_client` / `chain_type` / `is_embedded` forwarded per wallet. | +| **3 — attribution fell on an arbitrary wallet** | Only the **active** wallet updates the SDK's current address/user; the rest are clustering‑only. | +| **4 — dedup suppressed the DID re‑emit** | `userId` folded into the session dedup key, so attaching a DID to an already‑identified wallet re‑emits (and repeats still dedupe). | +| **5 — React freshness** | Integrator's own `useEffect(…, [user])` + the flag; no SDK hook. | + +### Attribution model (the core design) + +Every linked wallet is identified for clustering, but only **one** wallet may own +the SDK's "current address" (what later `track()`/`page()` events attribute to). + +- The concrete `identify()` impl carries an **internal `setActive` flag** — *not* + on the public `IFormoAnalytics.identify` overloads/interface. `setActive:false` + emits + dedupes the wallet↔user link but does **not** touch `currentAddress`, + `currentUserId`, or the user‑id cookie. +- `identifyPrivyUser` promotes exactly one wallet with `setActive:true` and marks + the rest `setActive:false`. Ordering is irrelevant; there is no snapshot/restore. +- **Active‑wallet resolution:** `activeAddress` (matched **strictly**) → + `user.wallet` (Privy's surfaced primary) → last‑external heuristic. +- A connected wallet that **isn't** linked in Privy is preserved: a strict, + unmatched `activeAddress` promotes no wallet, so the current address/user are + left untouched. +- **Chain state:** `identifyPrivyUser` clears `currentChainId` when the active + wallet's chain namespace no longer matches the current chain id (e.g. a Solana + wallet while an EVM chain id was current). It reconciles **before** emitting — + so identifies aren't dropped by an `excludeChains` gate — and does so for both + the `identify(user,{privy:true})` and direct `identifyPrivyUser()` paths. + +### Validated against Privy docs + +- `user.linkedAccounts` (with wallet addresses) **is** available on the frontend + via `usePrivy()` — no server call. This is the clustering source. +- `user.wallet` is Privy's surfaced **primary** wallet (a reasonable default), + but not a live "active" designation. +- The genuinely **active** wallet is a runtime `useWallets()` concept + (`wallets[0]`), which the SDK can't read from outside React — hence the + optional `activeAddress`. + +### Review findings resolved (Codex, PR #304) + +- Don't treat a normal identify carrying a `privy` property as the Privy form → + dispatch requires a Privy‑user‑shaped first arg (string `id`, no `address`). +- Preserve Solana casing when matching wallets → chain‑aware compare + (case‑insensitive for EVM `0x` hex, exact for Base58). +- Don't let `user.wallet` clobber a connected wallet → prefer the SDK's connected + `currentAddress`; unmatched connected wallets are preserved. +- Encode user IDs before storing dedup keys → percent‑encode key components + (a comma in an external userId no longer corrupts the comma‑joined cookie). +- Preserve the unmatched active wallet's address **and** user id → structural, + via `setActive` (non‑active identifies never repoint either). +- Clear stale chain id when activating a Solana wallet → chain‑namespace sync. +- Reconcile the chain **before** emitting (and in the helper, not the dispatch) → + identifies aren't dropped by a stale `excludeChains` chain id, and both entry + points reconcile identically. +- A `userId` equal to a provider RDNS can't collide with an anonymous + `address:rdns` dedup key → fixed 3‑component key shape when a userId is set. +- The dedup store no longer evicts identities for many‑wallet users → bounded by + serialized cookie size (~40 identities) instead of a fixed 20‑entry cap. +- The direct `identifyPrivyUser()` form preserves a connected wallet exactly like + the flag form → the helper itself reads the SDK's `currentAddress`. +- Clustering identifies carry the DID in the emitted event → identify events keep + their payload `user_id` instead of being overwritten by the active‑session + user id in `EventFactory.create()` (this one silently broke clustering for + every non‑active wallet). +- Suppressed visitors (opt‑out / excluded host/path/timezone) → the whole sync is + a no‑op, so chain reconciliation can't clear an excluded chain id while no + identify runs. + +### Verification + +685 tests passing (unit + real‑`identify()` integration + `EventFactory.create` +user‑id resolution + session dedup); lint and full build clean. + +## Phase 2 — deferred (product / backend) + +These need an ingest/event contract, not just SDK code, and are out of scope for +the identify one‑liner: + +- **Walletless users.** `identify()` is address‑keyed, so a Privy user with no + linked wallet is a logged no‑op. Surfacing account identity independent of a + wallet needs a `userId`‑keyed identify on ingest. +- **Unlink semantics.** The SDK emits positive wallet↔user links only; unlinking + re‑runs the effect for the smaller set but there is no "unlink" event, so links + are additive server‑side. +- **Users clustering surface.** A `/users` "Users" tab keyed on `user_id`, with a + fallback to `anonymous_id`/wallet clustering for clusters that have no + `user_id` yet. + +## Non‑goals (by design) + +- **Auto‑detecting Privy with zero integrator code.** The full `user` object + (linked wallets) lives in Privy's React context / SDK instance; the SDK can't + observe it from outside, and the persisted tokens carry only the DID, not the + wallet list. The integrator passes the reactive `user`. +- **An `alias()` API.** The shared‑`userId` model already merges wallets + server‑side. diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 211fe7d..a7a782e 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -59,6 +59,8 @@ import { parseChainId } from "./utils/chain"; import { WagmiEventHandler } from "./wagmi"; import { isSolanaChainId } from "./solana"; import { SolanaManager } from "./solana/SolanaManager"; +import { identifyPrivyUser } from "./privy"; +import type { PrivyUser } from "./privy"; /** * Constants for provider switching reasons @@ -690,17 +692,22 @@ export class FormoAnalytics implements IFormoAnalytics { * // Basic identify * formo.identify({ address: '0x...', userId: 'user123' }); * - * // With Privy user - * import { parsePrivyProperties } from '@formo/analytics'; + * // Privy: pass the usePrivy() user with `{ privy: true }` to identify every + * // linked wallet under the user's DID in one call. Attribution stays on the + * // already-connected wallet when there is one, else Privy's primary + * // (user.wallet); pass `activeAddress` to pin a specific wallet. * const { user } = usePrivy(); - * if (user) { - * const { properties, wallets } = parsePrivyProperties(user); - * for (const wallet of wallets) { - * formo.identify({ address: wallet.address, userId: user.id }, properties); - * } - * } + * if (user) formo.identify(user, { privy: true }); * ``` */ + async identify( + user: PrivyUser, + options: { + privy: true; + activeAddress?: string; + properties?: IFormoEventProperties; + } + ): Promise; async identify( params?: { address: Address; @@ -711,8 +718,75 @@ export class FormoAnalytics implements IFormoAnalytics { properties?: IFormoEventProperties, context?: IFormoEventContext, callback?: (...args: unknown[]) => void + ): Promise; + async identify( + paramsOrUser?: + | { + address: Address; + providerName?: string; + userId?: string; + rdns?: string; + } + | PrivyUser, + propertiesOrOptions?: + | IFormoEventProperties + | { privy: true; activeAddress?: string; properties?: IFormoEventProperties }, + context?: IFormoEventContext, + callback?: (...args: unknown[]) => void ): Promise { try { + // Privy convenience form: identify(user, { privy: true, activeAddress? }). + // Delegate to the Privy adapter, which expands the user's linked wallets + // into one identify per wallet under the shared DID. Kept as a thin + // dispatch so the Privy-specific logic stays in the privy module. + // + // The `{ privy: true }` flag alone is not enough to switch forms: + // `IFormoEventProperties` is an open record, so a normal identify could + // legitimately carry a property named `privy`. Only take the Privy branch + // when the first argument is actually Privy-user-shaped (a string `id`, + // and not an address-keyed identify params object). + const maybeUser = paramsOrUser as + | (Partial & { address?: unknown }) + | undefined; + if ( + propertiesOrOptions && + (propertiesOrOptions as { privy?: unknown }).privy === true && + maybeUser && + typeof maybeUser.id === "string" && + maybeUser.address === undefined + ) { + const opts = propertiesOrOptions as { + activeAddress?: string; + properties?: IFormoEventProperties; + }; + // identifyPrivyUser records every linked wallet for clustering WITHOUT + // touching active state (internal setActive:false), promotes only the + // resolved active wallet, and reconciles the chain id with that wallet's + // namespace before emitting. It reads this.currentAddress itself to + // preserve an already-connected wallet, so this dispatch is a thin + // pass-through and both entry points behave identically. + await identifyPrivyUser(this, maybeUser as PrivyUser, { + activeAddress: opts.activeAddress, + properties: opts.properties, + }); + return; + } + + const params = paramsOrUser as + | { + address: Address; + providerName?: string; + userId?: string; + rdns?: string; + // Internal only (not on the public IFormoAnalytics.identify overloads): + // when false, record the wallet↔user link for clustering/dedup but do + // NOT change the SDK's active identity. Used by identifyPrivyUser so + // clustering identifies for non-active wallets don't hijack attribution. + setActive?: boolean; + } + | undefined; + const properties = propertiesOrOptions as IFormoEventProperties | undefined; + // identify() writes the user-id cookie and marks wallet // identification before trackEvent's consent check — gate the whole // method so a suppressed visitor or excluded environment (opt-out / @@ -790,7 +864,7 @@ export class FormoAnalytics implements IFormoAnalytics { return; } - const { address, providerName, userId, rdns } = params; + const { address, providerName, userId, rdns, setActive } = params; // Runtime validation: address is required if (!address) { @@ -801,25 +875,38 @@ export class FormoAnalytics implements IFormoAnalytics { // Explicit identify logger.info("Identify", address, userId, providerName, rdns); const validAddress = validateAddress(address); - if (validAddress) { - this.currentAddress = validAddress; - this.persistActiveWallet(); - } else { + if (!validAddress) { logger.warn?.("Invalid address provided to identify:", address); return; } - if (userId) { - this.currentUserId = userId; - const domain = getIdentityCookieDomain(this.crossSubdomainCookies); - cookie().set(SESSION_USER_ID_KEY, userId, { - path: "/", - ...getIdentityCookieSecurity(), - ...(domain ? { domain } : {}), - }); + // Promote this wallet to the SDK's active identity — the (currentAddress, + // currentUserId) pair later events are attributed to — unless the caller + // opts out with setActive:false. A non-active identify still emits its + // event and marks dedup below (for clustering), it just doesn't repoint + // attribution. Gating address and userId together prevents leaving the + // active address paired with a different wallet's user id. + if (setActive !== false) { + this.currentAddress = validAddress; + this.persistActiveWallet(); + if (userId) { + this.currentUserId = userId; + const domain = getIdentityCookieDomain(this.crossSubdomainCookies); + cookie().set(SESSION_USER_ID_KEY, userId, { + path: "/", + ...getIdentityCookieSecurity(), + ...(domain ? { domain } : {}), + }); + } } - // Check for duplicate identify events in this session - const isAlreadyIdentified = this.session.isWalletIdentified(validAddress, rdns || ""); + // Check for duplicate identify events in this session. The userId is + // folded into the dedup key so re-identifying an already-seen wallet with + // a newly-attached userId (e.g. a Privy DID after login) still emits. + const isAlreadyIdentified = this.session.isWalletIdentified( + validAddress, + rdns || "", + userId + ); logger.debug("Identify: Checking deduplication", { validAddress, @@ -841,7 +928,7 @@ export class FormoAnalytics implements IFormoAnalytics { } // Mark as identified before emitting the event - this.session.markWalletIdentified(validAddress, rdns || ""); + this.session.markWalletIdentified(validAddress, rdns || "", userId); await this.trackEvent( EventType.IDENTIFY, @@ -860,6 +947,34 @@ export class FormoAnalytics implements IFormoAnalytics { } } + /** + * Reconcile currentChainId with a newly-activated Privy wallet's chain + * namespace. identify() sets currentAddress but never touches the chain id + * (that comes from connect()/chain()/wagmi), so activating e.g. a Solana + * wallet while an EVM chain id is current would leave the address paired with + * a mismatched chain in events, excludeChains, and the active-wallet cookie. + * + * We can't infer the wallet's specific chain id from Privy's chainType, so on + * a namespace mismatch we clear the chain id rather than assert a wrong one; a + * real wallet connect will set the correct chain. Same-namespace activations + * (and unknown chainTypes) leave the chain id untouched. + * + * @internal Not part of the public IFormoAnalytics contract — invoked by + * `identifyPrivyUser` (via a structural cast) before it emits, so both the + * `identify(user,{privy:true})` and direct `identifyPrivyUser()` paths + * reconcile the chain. + */ + syncPrivyActiveChain(chainType?: string): void { + if (this.currentChainId === undefined || this.currentChainId === null) return; + if (!chainType) return; + const walletIsSolana = chainType.toLowerCase() === "solana"; + const currentIsSolana = isSolanaChainId(this.currentChainId); + if (walletIsSolana !== currentIsSolana) { + this.currentChainId = undefined; + this.persistActiveWallet(); + } + } + /** * Emits a detect wallet event with current wallet provider info. * @param {string} params.providerName @@ -1884,8 +1999,10 @@ export class FormoAnalytics implements IFormoAnalytics { * before reaching the `shouldTrack()` event gate (identify/connect/detect) * check this first so suppressed visitors leave no cookies or session state. * @returns {boolean} True if all tracking and persistence must be suppressed + * @internal Also read by `identifyPrivyUser` (via a structural cast) so the + * Privy sync skips chain reconciliation and emission for suppressed visitors. */ - private isTrackingSuppressed(): boolean { + isTrackingSuppressed(): boolean { return this.hasOptedOutTracking() || this.isCurrentEnvironmentExcluded(); } diff --git a/src/core.ts b/src/core.ts index 9b34781..634fbb2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -9,8 +9,9 @@ export * from "./FormoAnalytics"; export * from "./types"; export { formofy } from "./initialization"; -export { parsePrivyProperties } from "./privy"; +export { parsePrivyProperties, identifyPrivyUser } from "./privy"; export type { + IdentifyPrivyUserOptions, PrivyUser, PrivyLinkedAccount, PrivyAccountType, diff --git a/src/event/EventFactory.ts b/src/event/EventFactory.ts index 1a72af0..9156dbb 100644 --- a/src/event/EventFactory.ts +++ b/src/event/EventFactory.ts @@ -808,7 +808,17 @@ class EventFactory implements IEventFactory { const chainId = 'chainId' in event ? (event.chainId as ChainID) : undefined; formoEvent.address = this.validateEventAddress(address, chainId); } - formoEvent.user_id = userId || null; + // An identify event asserts an explicit identity in its own payload (e.g. a + // Privy DID for each wallet being clustered). Keep that payload user_id + // rather than overwriting it with the active-session user id — otherwise a + // clustering identify that intentionally leaves the active user unchanged + // (setActive:false) would be stripped of its DID, defeating server-side + // wallet clustering. Fall back to the active-session user id when the + // identify payload carries none; all other events use the session user id. + formoEvent.user_id = + event.type === "identify" + ? formoEvent.user_id ?? userId ?? null + : userId || null; return formoEvent as IFormoEvent; } diff --git a/src/privy/index.ts b/src/privy/index.ts index 4590717..904140c 100644 --- a/src/privy/index.ts +++ b/src/privy/index.ts @@ -1,11 +1,16 @@ /** * Privy integration module * - * Provides utilities for enriching wallet profiles with Privy user data. - * This module exports the property extraction utility and related types. + * Provides utilities for enriching wallet profiles with Privy user data: + * `parsePrivyProperties` (low-level parsing) and `identifyPrivyUser` (identify + * every linked wallet under the user's DID). The same behavior is also + * available as `formo.identify(user, { privy: true })`. + * + * This module is React-free so it can be used from the `core` entry. */ -export { parsePrivyProperties } from "./utils"; +export { parsePrivyProperties, identifyPrivyUser } from "./utils"; +export type { IdentifyPrivyUserOptions } from "./utils"; export type { PrivyUser, PrivyLinkedAccount, diff --git a/src/privy/utils.ts b/src/privy/utils.ts index a5d727f..410927f 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -3,10 +3,40 @@ */ import { + PrivyLinkedAccount, PrivyProfileProperties, PrivyUser, PrivyWalletInfo, } from "./types"; +import { IFormoAnalytics } from "../types/base"; +import { IFormoEventProperties } from "../types/events"; +import { logger } from "../logger"; + +/** + * Whether a Privy linked account is a usable wallet — an EVM/Solana wallet or + * smart wallet with an address. + */ +function isPrivyWalletAccount(account: PrivyLinkedAccount): boolean { + return ( + (account.type === "wallet" || account.type === "smart_wallet") && + !!account.address + ); +} + +/** A 0x-prefixed 20-byte hex string (prefix and hex are case-insensitive). */ +const EVM_ADDRESS_RE = /^0x[0-9a-f]{40}$/i; + +/** + * Compare two wallet addresses for equality. EVM addresses are hex and + * case-insensitive, so they are folded to lowercase; all other chains (notably + * Solana, whose Base58 addresses are case-sensitive) are compared exactly, so a + * case difference never matches the wrong wallet. + */ +function sameAddress(a: string, b: string): boolean { + return EVM_ADDRESS_RE.test(a) && EVM_ADDRESS_RE.test(b) + ? a.toLowerCase() === b.toLowerCase() + : a === b; +} /** * Extract profile properties and wallet addresses from a Privy user object. @@ -14,6 +44,11 @@ import { * Parses the Privy user's linked accounts into a flat properties object * (email, social accounts, etc.) and extracts all linked wallet addresses. * + * For most apps prefer the {@link identifyPrivyUser} one-liner, which builds on + * this function and also forwards per-wallet metadata and handles event + * attribution. Use `parsePrivyProperties` directly only for advanced/custom + * flows. + * * @param user - The Privy user object from `usePrivy()` * @returns An object with `properties` and `wallets` * @@ -207,7 +242,7 @@ export function parsePrivyProperties(user: PrivyUser): { // Extract wallet addresses const wallets: PrivyWalletInfo[] = accounts - .filter((a) => (a.type === "wallet" || a.type === "smart_wallet") && a.address) + .filter(isPrivyWalletAccount) .map((a) => ({ address: a.address!, walletClient: (a.walletClientType || a.walletClient) ?? undefined, @@ -218,3 +253,227 @@ export function parsePrivyProperties(user: PrivyUser): { return { properties, wallets }; } + +/** + * Options for {@link identifyPrivyUser}. + */ +export interface IdentifyPrivyUserOptions { + /** + * Optional override for the wallet that should own event attribution — the + * one promoted to the SDK's current address/user, while every other linked + * wallet is recorded only for clustering. + * + * You usually don't need this. When omitted, the helper first uses the wallet + * the SDK already treats as active (a prior wagmi/EIP-1193 connect), then + * Privy's own surfaced wallet (`user.wallet`), then a best-effort guess + * (embedded wallets deprioritized, so the last external wallet). Pass it only + * when you want to pin attribution to a specific wallet — e.g. the currently + * connected wallet from `useWallets()[0]?.address`, which reflects the live + * active wallet more precisely than `user.wallet`. + * + * Matched strictly: if it doesn't correspond to one of the user's linked + * wallets, no wallet is promoted and the SDK's current wallet is left as-is + * (so a connected wallet that isn't linked in Privy is preserved). + */ + activeAddress?: string; + + /** + * Extra properties merged into every identify call, on top of the profile + * properties parsed from the Privy user (email, socials, DID, …) and the + * per-wallet metadata (`wallet_client`, `chain_type`, `is_embedded`). + * + * Note: because identify events are deduped per `(wallet, user)` within a + * session, these properties are effectively captured on the *first* identify + * for each wallet and are not refreshed by later calls in the same session. + * Treat them as identity metadata set at identify time, not a live profile. + */ + properties?: IFormoEventProperties; +} + +/** + * Identify every wallet linked to a Privy user under that user's Privy DID. + * + * This is the one-liner replacement for hand-rolling a loop over + * {@link parsePrivyProperties}. For each linked wallet it calls + * `analytics.identify({ address, userId: user.id }, …)` with the shared + * profile properties plus that wallet's `wallet_client`, `chain_type`, and + * `is_embedded` metadata. Because every wallet is tagged with the same Privy + * `userId`, Formo can cluster them server-side into a single user. + * + * Attribution: only the active wallet (see + * {@link IdentifyPrivyUserOptions.activeAddress}) promotes the SDK's current + * address/user; every other linked wallet is recorded purely for clustering and + * does not repoint attribution. Because the clustering identifies don't touch + * active state, a connected wallet that isn't linked in Privy is left untouched + * rather than overwritten. This does not change the public `identify()` API. + * + * Before emitting, it reconciles the SDK's chain id with the active wallet's + * chain namespace (clearing a stale EVM chain id when a Solana wallet becomes + * active, and vice versa), so identifies aren't dropped by an `excludeChains` + * gate and later events aren't paired with the wrong chain. This happens here, + * so the direct helper and the `formo.identify(user, { privy: true })` form + * behave identically. + * + * Returns the active linked wallet's `{ address, chainType }` (the one now + * owning attribution), or `undefined` when no linked wallet matched the + * requested active address (or the user had no wallets). + * + * All linked wallet addresses used here come from `user.linkedAccounts`, which + * is fully available on the frontend from Privy's `usePrivy()` hook. + * + * Note: `identify()` is keyed on a wallet address, so a Privy user with no + * linked wallet is a no-op (nothing is emitted). Attaching a user identity that + * has no wallet is out of scope for this address-keyed helper. + * + * @param analytics - The Formo analytics instance (e.g. from `useFormo()`) + * @param user - The Privy user object from `usePrivy()` + * @param options - See {@link IdentifyPrivyUserOptions} + * + * @example + * ```ts + * import { identifyPrivyUser } from '@formo/analytics'; + * + * const { user } = usePrivy(); + * const { wallets } = useWallets(); + * if (user) { + * // activeAddress is optional — omit it if the SDK already tracks the + * // connected wallet via a wagmi/EIP-1193 connect. + * await identifyPrivyUser(formo, user, { + * activeAddress: wallets[0]?.address, + * }); + * } + * ``` + */ +export async function identifyPrivyUser( + analytics: IFormoAnalytics, + user: PrivyUser, + options: IdentifyPrivyUserOptions = {} +): Promise<{ address: string; chainType?: string } | undefined> { + if (!analytics || !user) return undefined; + + const target = analytics as unknown as PrivySyncTarget; + + // If tracking is suppressed for this visitor/route (opt-out / timezone / host / + // path), do nothing. The inner identify() calls would each be gated anyway, + // but the chain reconciliation below runs BEFORE them — so without this guard + // it would clear an excluded chain id while no identify actually happens, + // leaving later events on an allowed route unable to apply `excludeChains`. + if (target.isTrackingSuppressed?.()) return undefined; + + const { properties, wallets } = parsePrivyProperties(user); + + // identify() is keyed on a wallet address, so with no linked wallets there is + // nothing to attach the Privy identity to. Log it so a walletless user (or a + // pre-wallet account-creation flow) doesn't silently disappear. + if (wallets.length === 0) { + logger.info( + "identifyPrivyUser: user has no linked wallets; nothing to identify", + user.id + ); + return undefined; + } + + const baseProperties: IFormoEventProperties = { + ...properties, + ...options.properties, + }; + + // Resolve the wallet that should own attribution. An explicit activeAddress + // wins; otherwise fall back to the address Formo already treats as active + // (e.g. from a wagmi/EIP-1193 connect) BEFORE user.wallet, so the direct + // identifyPrivyUser() form preserves a connected wallet exactly like the + // identify(user,{privy:true}) form. A connected wallet that isn't linked here + // simply doesn't match, leaving attribution untouched. + const activeWallet = resolveActiveWallet( + wallets, + options.activeAddress ?? target.currentAddress, + user.wallet?.address + ); + + // Reconcile the chain BEFORE emitting any identify. identify() runs each event + // through the tracking gate (which enforces `excludeChains` against the + // current chain id); if the active wallet is on a different chain namespace + // than the stale current chain id (e.g. a Solana wallet while an EVM chain was + // current, and that EVM chain is excluded), reconciling first prevents the + // clustering identifies from being silently dropped. Doing it here — rather + // than in the identify(user,{privy:true}) dispatch — means the direct + // identifyPrivyUser() entry point gets the same treatment. + target.syncPrivyActiveChain?.(activeWallet?.chainType); + + // Emit an identify for every linked wallet under the shared DID. Only the + // active wallet promotes the SDK's active identity (via the internal + // setActive flag); the rest are recorded purely for clustering and never + // repoint attribution — so ordering is irrelevant, and a connected wallet + // that isn't linked here (activeWallet === undefined) leaves the SDK's + // current address/user untouched instead of being overwritten. + const identify = target.identify.bind(analytics); + for (const wallet of wallets) { + const walletProperties: IFormoEventProperties = { + ...baseProperties, + is_embedded: wallet.isEmbedded, + }; + if (wallet.walletClient) walletProperties.wallet_client = wallet.walletClient; + if (wallet.chainType) walletProperties.chain_type = wallet.chainType; + + await identify( + { + address: wallet.address, + userId: user.id, + setActive: wallet === activeWallet, + }, + walletProperties + ); + } + + return activeWallet + ? { address: activeWallet.address, chainType: activeWallet.chainType } + : undefined; +} + +/** + * Internal capabilities of the analytics instance that `identifyPrivyUser` uses + * but that are not part of the public {@link IFormoAnalytics} contract: + * `identify` with the `setActive` flag (clustering identifies that don't repoint + * attribution), `syncPrivyActiveChain` (reconcile the chain id with the active + * wallet's namespace), `isTrackingSuppressed` (skip everything for suppressed + * visitors), and read access to `currentAddress` (the wallet Formo already + * treats as active). All optional so a minimal stub still works. + */ +interface PrivySyncTarget { + readonly currentAddress?: string; + identify: ( + params: { address: string; userId?: string; setActive?: boolean }, + properties?: IFormoEventProperties + ) => Promise; + syncPrivyActiveChain?(chainType?: string): void; + isTrackingSuppressed?(): boolean; +} + +/** + * Choose the linked wallet that should own event attribution. + * + * An explicit `activeAddress` (a caller override, or the SDK's already-connected + * wallet) is matched strictly and never falls back: a connected wallet that is + * not in `linkedAccounts` resolves to `undefined`, so the sync leaves the + * current wallet untouched. With no active address, prefer Privy's surfaced + * primary (`user.wallet`), then a best-effort guess (embedded wallets first, so + * the last external wallet). Address comparison is chain-appropriate so Solana + * casing isn't mismatched. + */ +function resolveActiveWallet( + wallets: PrivyWalletInfo[], + activeAddress?: string, + primaryAddress?: string +): PrivyWalletInfo | undefined { + if (activeAddress) { + return wallets.find((w) => sameAddress(w.address, activeAddress)); + } + if (primaryAddress) { + const primary = wallets.find((w) => sameAddress(w.address, primaryAddress)); + if (primary) return primary; + } + const external = wallets.filter((w) => !w.isEmbedded); + return external.length > 0 + ? external[external.length - 1] + : wallets[wallets.length - 1]; +} diff --git a/src/session/index.ts b/src/session/index.ts index 38d0112..d258220 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -37,15 +37,20 @@ export interface IFormoAnalyticsSession { * Check if a wallet-address pair has been identified in this session * @param address The wallet address * @param rdns The reverse domain name (RDNS) of the wallet provider + * @param userId Optional external user ID (e.g. a Privy DID). When provided, + * it is folded into the dedup key so attaching a new user ID to an + * already-identified wallet re-emits instead of being silently deduped. */ - isWalletIdentified(address: string, rdns: string): boolean; - + isWalletIdentified(address: string, rdns: string, userId?: string): boolean; + /** * Mark a wallet-address pair as identified in this session * @param address The wallet address * @param rdns The reverse domain name (RDNS) of the wallet provider + * @param userId Optional external user ID (e.g. a Privy DID). See + * {@link isWalletIdentified} for how it affects the dedup key. */ - markWalletIdentified(address: string, rdns: string): void; + markWalletIdentified(address: string, rdns: string, userId?: string): void; } /** @@ -54,23 +59,66 @@ export interface IFormoAnalyticsSession { * Tracks: * - Detected wallets (by RDNS) - to prevent duplicate detection events * - Identified wallet-address pairs - to prevent duplicate identification events - * + * * Session data expires at end of day (86400 seconds). */ const MAX_SESSION_ENTRIES = 20; +/** + * Byte budget for the identified-wallet cookie value. + * + * A Privy user can identify far more than 20 wallets in one session (an 8+ + * wallet user is the motivating case), so a fixed entry count would evict + * `(wallet, userId)` keys and let a later sync re-emit them. Instead we bound + * the store by serialized size and evict oldest only when it would overflow the + * cookie — so every identity that fits is retained. Kept well under the ~4KB + * per-cookie browser limit (leaving room for the cookie name and attributes); + * that comfortably holds ~40 identities, beyond any realistic linked-wallet set. + */ +const MAX_IDENTIFIED_BYTES = 3500; + export class FormoAnalyticsSession implements IFormoAnalyticsSession { /** * Generate a unique key for wallet identification tracking - * Combines address and RDNS to track specific wallet-address combinations - * + * Combines address, RDNS, and (optionally) the external user ID to track + * specific wallet-address-user combinations. + * + * Folding the user ID into the key means the same wallet identified first + * anonymously and later with a user ID (e.g. after a Privy login attaches a + * DID) produces two distinct keys, so the second identify is not deduped. + * When `userId` is omitted the key is unchanged (`address` or `address:rdns`), + * preserving backward compatibility with keys already stored in browsers. + * * @param address The wallet address * @param rdns The reverse domain name of the wallet provider + * @param userId Optional external user ID (e.g. a Privy DID) * @returns A unique identification key */ - private generateIdentificationKey(address: string, rdns: string): string { - // If rdns is missing, use address-only key as fallback for empty identifies - return rdns ? `${address}:${rdns}` : address; + private generateIdentificationKey( + address: string, + rdns: string, + userId?: string + ): string { + // Percent-encode each component before joining. The identified-wallet list + // is persisted comma-joined in a cookie and later split on commas, so a raw + // comma in an arbitrary external userId would corrupt the key and defeat + // dedup (the same identify would re-emit on every call). Encoding also keeps + // the ":" separator unambiguous. Addresses and RDNS contain no reserved + // characters, so their encoded form is unchanged — existing stored keys + // still match (backward compatible). + const parts = [encodeURIComponent(address)]; + if (userId) { + // With a userId, always emit the rdns slot (even when empty) so the tuple + // has a fixed 3-component shape. Otherwise a userId that happens to equal + // a provider RDNS (e.g. "io.metamask") would produce the same key as an + // anonymous `address:rdns` identify and be wrongly deduped. userId keys + // are new, so this shape change has no backward-compat cost. + parts.push(encodeURIComponent(rdns || "")); + parts.push(encodeURIComponent(userId)); + } else if (rdns) { + parts.push(encodeURIComponent(rdns)); + } + return parts.join(":"); } /** @@ -113,8 +161,12 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { * @param rdns The reverse domain name of the wallet provider * @returns true if this wallet-address pair has been identified */ - public isWalletIdentified(address: string, rdns: string): boolean { - const identifiedKey = this.generateIdentificationKey(address, rdns); + public isWalletIdentified( + address: string, + rdns: string, + userId?: string + ): boolean { + const identifiedKey = this.generateIdentificationKey(address, rdns, userId); const cookieValue = cookie().get(SESSION_WALLET_IDENTIFIED_KEY); const identifiedWallets = cookieValue?.split(",") || []; const isIdentified = identifiedWallets.includes(identifiedKey); @@ -135,18 +187,29 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { * @param address The wallet address * @param rdns The reverse domain name of the wallet provider */ - public markWalletIdentified(address: string, rdns: string): void { - const identifiedKey = this.generateIdentificationKey(address, rdns); + public markWalletIdentified( + address: string, + rdns: string, + userId?: string + ): void { + const identifiedKey = this.generateIdentificationKey(address, rdns, userId); const identifiedWallets = cookie().get(SESSION_WALLET_IDENTIFIED_KEY)?.split(",") || []; const alreadyExists = identifiedWallets.includes(identifiedKey); if (!alreadyExists) { identifiedWallets.push(identifiedKey); - if (identifiedWallets.length > MAX_SESSION_ENTRIES) { - identifiedWallets.splice(0, identifiedWallets.length - MAX_SESSION_ENTRIES); + // Bound the stored list by serialized size (not a fixed entry count) so a + // many-wallet Privy user's identities all persist, evicting oldest only if + // the value would overflow the cookie. Keep at least the just-added key. + let newValue = identifiedWallets.join(","); + while ( + identifiedWallets.length > 1 && + newValue.length > MAX_IDENTIFIED_BYTES + ) { + identifiedWallets.shift(); + newValue = identifiedWallets.join(","); } - const newValue = identifiedWallets.join(","); cookie().set(SESSION_WALLET_IDENTIFIED_KEY, newValue, { // Expires by the end of the day expires: new Date(Date.now() + 86400 * 1000).toUTCString(), diff --git a/src/types/base.ts b/src/types/base.ts index bd152e9..ea18113 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -7,6 +7,7 @@ import { } from "./events"; import { EIP1193Provider } from "./provider"; import { SolanaOptions } from "../solana/types"; +import type { PrivyUser } from "../privy/types"; export type Nullable = T | null; // Decimal chain ID @@ -89,6 +90,22 @@ export interface IFormoAnalytics { context?: IFormoEventContext, callback?: (...args: unknown[]) => void ): Promise; + /** + * Privy convenience form. Pass the `usePrivy()` user with `{ privy: true }` + * to identify every wallet linked to that Privy account under the user's DID + * in a single call. The SDK expands `user.linkedAccounts` and forwards each + * wallet's metadata; only the active wallet (explicit `activeAddress`, else + * the already-connected wallet, else Privy's `user.wallet`) takes over event + * attribution — the rest are recorded purely for identity clustering. + */ + identify( + user: PrivyUser, + options: { + privy: true; + activeAddress?: string; + properties?: IFormoEventProperties; + } + ): Promise; identify( params: { address: Address; diff --git a/test/lib/event/EventFactoryUserId.spec.ts b/test/lib/event/EventFactoryUserId.spec.ts new file mode 100644 index 0000000..b1f7e30 --- /dev/null +++ b/test/lib/event/EventFactoryUserId.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; +import { JSDOM } from "jsdom"; +import { EventFactory } from "../../../src/event/EventFactory"; +import { initStorageManager } from "../../../src/storage"; + +/** + * EventFactory.create user_id resolution. + * + * An identify event asserts an explicit identity in its own payload (e.g. a + * Privy DID for each wallet being clustered). It must keep that payload user_id + * rather than being overwritten by the active-session user id passed in — that + * overwrite would strip the DID from every clustering identify emitted with + * setActive:false and break server-side wallet clustering. + */ +describe("EventFactory.create user_id resolution", () => { + let jsdom: JSDOM; + let eventFactory: EventFactory; + + const ADDRESS = "0x1095bBe769fDab716A823d0f7149CAD713d20A13"; + const DID = "did:privy:abc123"; + + beforeEach(() => { + jsdom = new JSDOM( + "", + { url: "https://formo.so/" } + ); + Object.defineProperty(global, "window", { + value: jsdom.window, writable: true, configurable: true, + }); + Object.defineProperty(global, "document", { + value: jsdom.window.document, writable: true, configurable: true, + }); + Object.defineProperty(global, "location", { + value: jsdom.window.location, writable: true, configurable: true, + }); + Object.defineProperty(global, "globalThis", { + value: jsdom.window, writable: true, configurable: true, + }); + Object.defineProperty(global, "navigator", { + value: jsdom.window.navigator, writable: true, configurable: true, + }); + Object.defineProperty(global, "screen", { + value: jsdom.window.screen, writable: true, configurable: true, + }); + Object.defineProperty(global, "Intl", { + value: { + DateTimeFormat: () => ({ + resolvedOptions: () => ({ timeZone: "America/New_York" }), + }), + }, + writable: true, configurable: true, + }); + Object.defineProperty(global, "localStorage", { + value: jsdom.window.localStorage, writable: true, configurable: true, + }); + Object.defineProperty(global, "sessionStorage", { + value: jsdom.window.sessionStorage, writable: true, configurable: true, + }); + Object.defineProperty(global, "crypto", { + value: { randomUUID: () => "12345678-1234-1234-1234-123456789abc" }, + writable: true, configurable: true, + }); + + initStorageManager("test-write-key"); + eventFactory = new EventFactory(); + }); + + afterEach(() => { + delete (global as any).window; + delete (global as any).document; + delete (global as any).location; + delete (global as any).globalThis; + delete (global as any).navigator; + delete (global as any).screen; + delete (global as any).Intl; + delete (global as any).localStorage; + delete (global as any).sessionStorage; + delete (global as any).crypto; + if (jsdom) jsdom.window.close(); + }); + + it("keeps the identify payload user_id (DID) over the active-session user id", async () => { + // A clustering identify (setActive:false) passes the DID in the payload but + // the active-session user id is unchanged (here: undefined). + const result = await eventFactory.create( + { type: "identify", address: ADDRESS, userId: DID } as any, + ADDRESS, + undefined + ); + expect(result.user_id).to.equal(DID); + }); + + it("keeps the payload DID even when the active-session user id is a different value", async () => { + const result = await eventFactory.create( + { type: "identify", address: ADDRESS, userId: DID } as any, + ADDRESS, + "some-other-user" + ); + expect(result.user_id).to.equal(DID); + }); + + it("falls back to the active-session user id when the identify payload has none", async () => { + const result = await eventFactory.create( + { type: "identify", address: ADDRESS } as any, + ADDRESS, + "session-user" + ); + expect(result.user_id).to.equal("session-user"); + }); + + it("uses the active-session user id for non-identify events", async () => { + const result = await eventFactory.create( + { type: "track", event: "custom" } as any, + ADDRESS, + "session-user" + ); + expect(result.user_id).to.equal("session-user"); + }); +}); diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts new file mode 100644 index 0000000..7b7fc14 --- /dev/null +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -0,0 +1,397 @@ +import { describe, it, beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; +import * as sinon from "sinon"; +import { JSDOM } from "jsdom"; +import { FormoAnalytics } from "../../src/FormoAnalytics"; +import { identifyPrivyUser, PrivyUser } from "../../src/privy"; +import { initStorageManager } from "../../src/storage"; + +/** + * End-to-end coverage for identifyPrivyUser driving the REAL FormoAnalytics + * identify() — not a stub. Verifies that: + * - every linked wallet emits an identify event tagged with the Privy DID, + * - the per-wallet metadata is forwarded, + * - only the active wallet ends up as the SDK's currentAddress (no hijack), + * - the userId-aware session dedup re-emits when a DID is attached to a wallet + * that was previously identified anonymously. + */ +describe("identifyPrivyUser (integration with real identify)", () => { + let sandbox: sinon.SinonSandbox; + let jsdom: JSDOM; + + const EMBEDDED = "0x1111111111111111111111111111111111111111"; + const EXTERNAL = "0x2222222222222222222222222222222222222222"; + const EXTERNAL_2 = "0x3333333333333333333333333333333333333333"; + const DID = "did:privy:integration"; + + async function makeAnalytics( + tracking?: boolean | { excludeChains?: number[] } + ): Promise { + // wagmi mode skips browser-dependent EIP-1193/EIP-6963 provider detection. + const mockWagmiConfig = { + subscribe: sandbox.stub().returns(() => {}), + state: { + status: "disconnected", + connections: new Map(), + current: undefined, + chainId: undefined, + }, + _internal: { store: { subscribe: sandbox.stub().returns(() => {}) } }, + }; + return FormoAnalytics.init("test-write-key", { + wagmi: { config: mockWagmiConfig as any }, + ...(tracking !== undefined ? { tracking: tracking as any } : {}), + }); + } + + /** Stub the event sink and return the list of emitted identify events. */ + function captureIdentifies(formo: FormoAnalytics): any[] { + const events: any[] = []; + sandbox + .stub((formo as any).eventManager, "addEvent") + .callsFake(async (event: any) => { + events.push(event); + }); + return events; + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + jsdom = new JSDOM("", { + url: "https://example.com", + }); + Object.defineProperty(global, "window", { + value: jsdom.window, writable: true, configurable: true, + }); + Object.defineProperty(global, "document", { + value: jsdom.window.document, writable: true, configurable: true, + }); + Object.defineProperty(global, "location", { + value: jsdom.window.location, writable: true, configurable: true, + }); + Object.defineProperty(global, "globalThis", { + value: jsdom.window, writable: true, configurable: true, + }); + Object.defineProperty(global, "navigator", { + value: jsdom.window.navigator, writable: true, configurable: true, + }); + Object.defineProperty(global, "localStorage", { + value: jsdom.window.localStorage, writable: true, configurable: true, + }); + Object.defineProperty(global, "sessionStorage", { + value: jsdom.window.sessionStorage, writable: true, configurable: true, + }); + Object.defineProperty(global, "crypto", { + value: { randomUUID: () => "mock-uuid-1234" }, + writable: true, configurable: true, + }); + initStorageManager("test-write-key"); + }); + + afterEach(() => { + sandbox.restore(); + delete (global as any).window; + delete (global as any).document; + delete (global as any).location; + delete (global as any).globalThis; + delete (global as any).navigator; + delete (global as any).localStorage; + delete (global as any).sessionStorage; + delete (global as any).crypto; + }); + + it("emits one identify per wallet tagged with the DID and pins attribution to the active wallet", async () => { + const formo = await makeAnalytics(); + const events = captureIdentifies(formo); + + const user: PrivyUser = { + id: DID, + email: { address: "user@example.com" }, + linkedAccounts: [ + // active wallet is deliberately NOT last in link order, to prove the + // loop does not simply leave attribution on the last linked wallet. + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy", chainType: "ethereum" }, + { type: "wallet", address: EXTERNAL_2, walletClientType: "rainbow", chainType: "ethereum" }, + ], + }; + + await identifyPrivyUser(formo, user, { activeAddress: EXTERNAL }); + + // One identify event per linked wallet. + const addresses = events.map((e) => e.address.toLowerCase()); + expect(events).to.have.length(3); + expect(addresses).to.have.members([EMBEDDED, EXTERNAL, EXTERNAL_2]); + + // Every event carries the shared DID and profile properties. + for (const e of events) { + expect(e.userId).to.equal(DID); + expect(e.properties.privyDid).to.equal(DID); + expect(e.properties.email).to.equal("user@example.com"); + expect(e.properties).to.have.property("is_embedded"); + } + + // Attribution stays on the active wallet, not the last-linked wallet. + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("dispatches identify(user, { privy: true }) through the core identify()", async () => { + const formo = await makeAnalytics(); + const events = captureIdentifies(formo); + + const user: PrivyUser = { + id: DID, + email: { address: "user@example.com" }, + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy", chainType: "ethereum" }, + ], + }; + + // The single-call form: no separate helper or hook. + await formo.identify(user, { privy: true, activeAddress: EXTERNAL }); + + // Both linked wallets identified under the DID, with metadata forwarded. + expect(events).to.have.length(2); + expect(events.map((e) => e.address.toLowerCase())).to.have.members([ + EMBEDDED, + EXTERNAL, + ]); + for (const e of events) { + expect(e.userId).to.equal(DID); + expect(e.properties.email).to.equal("user@example.com"); + expect(e.properties).to.have.property("is_embedded"); + } + // Only the active wallet (setActive) owns attribution. + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("does not treat a normal identify carrying a `privy` property as the Privy form", async () => { + const formo = await makeAnalytics(); + const events = captureIdentifies(formo); + + // A regular identify whose params is address-shaped must not dispatch to the + // Privy form even if a property happens to be named `privy: true` — the + // wallet/user must still be recorded, not silently dropped. + await formo.identify( + { address: EXTERNAL, userId: "plain" }, + { privy: true, plan: "pro" } + ); + + expect(events).to.have.length(1); + expect(events[0].address.toLowerCase()).to.equal(EXTERNAL); + expect(events[0].userId).to.equal("plain"); + expect(events[0].properties.privy).to.equal(true); + expect(events[0].properties.plan).to.equal("pro"); + }); + + it("prefers the already-connected wallet over user.wallet for attribution", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + // A real connect sets currentAddress to the external wallet. + await formo.identify({ address: EXTERNAL, rdns: "io.metamask" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + + // user.wallet is the embedded wallet — Privy's primary, different from the + // wallet the user actually connected. + const user: PrivyUser = { + id: DID, + wallet: { address: EMBEDDED }, + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + + // No activeAddress passed: the flag form must keep attribution on the + // connected wallet, not overwrite it with user.wallet. + await formo.identify(user, { privy: true }); + + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("preserves the connected wallet via the direct identifyPrivyUser() form too", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + await formo.identify({ address: EXTERNAL, rdns: "io.metamask" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + + const user: PrivyUser = { + id: DID, + wallet: { address: EMBEDDED }, // Privy's primary is the embedded wallet + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + + // Direct helper, no activeAddress: it must read the SDK's connected wallet + // and keep attribution there, identical to the flag form (not switch to + // user.wallet). + await identifyPrivyUser(formo, user); + + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("preserves a connected wallet that is not linked in Privy", async () => { + const formo = await makeAnalytics(); + const events = captureIdentifies(formo); + + // Connect a wallet that is NOT among the user's Privy linked wallets + // (e.g. a wagmi wallet connected before being linked in Privy). + const UNLINKED = "0x4444444444444444444444444444444444444444"; + await formo.identify({ address: UNLINKED, rdns: "io.metamask" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(UNLINKED); + + const user: PrivyUser = { + id: DID, + wallet: { address: EMBEDDED }, + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + + await formo.identify(user, { privy: true }); + + // The linked wallets are still identified for clustering... + const linkedIdentifies = events.filter((e) => + [EMBEDDED, EXTERNAL].includes(e.address.toLowerCase()) + ); + expect(linkedIdentifies).to.have.length(2); + // ...but attribution is preserved on the connected (unlinked) wallet, not + // overwritten by an arbitrary linked wallet. + expect(formo.currentAddress?.toLowerCase()).to.equal(UNLINKED); + // ...and the unlinked wallet is NOT falsely tagged with the Privy DID + // (currentUserId must not have been repointed by the clustering identifies). + expect(formo.currentUserId).to.not.equal(DID); + }); + + it("clears a stale EVM chain id when the active Privy wallet is Solana", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + // A valid Base58 Solana address (canonical wrapped-SOL mint). + const SOL = "So11111111111111111111111111111111111111112"; + + // Establish an EVM chain first. + await formo.connect({ chainId: 1, address: EXTERNAL }); + expect(formo.currentChainId).to.equal(1); + + const user: PrivyUser = { + id: DID, + linkedAccounts: [ + { type: "wallet", address: SOL, walletClientType: "phantom", chainType: "solana" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + ], + }; + + // Make the Solana wallet the active one. + await formo.identify(user, { privy: true, activeAddress: SOL }); + + expect(formo.currentAddress).to.equal(SOL); + // The stale EVM chain id (1) must be cleared so the Solana address isn't + // paired with an EVM chain in events / the active-wallet cookie. + expect(formo.currentChainId).to.be.oneOf([undefined, null]); + }); + + it("still emits identifies when on an excluded chain but activating a Solana wallet", async () => { + // Chain 1 (EVM) is excluded from tracking. + const formo = await makeAnalytics({ excludeChains: [1] }); + const events = captureIdentifies(formo); + + const SOL = "So11111111111111111111111111111111111111112"; + + // The current chain is the excluded EVM chain. + await formo.connect({ chainId: 1, address: EXTERNAL }); + expect(formo.currentChainId).to.equal(1); + + const user: PrivyUser = { + id: DID, + linkedAccounts: [ + { type: "wallet", address: SOL, walletClientType: "phantom", chainType: "solana" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + ], + }; + + // Activate the Solana wallet. The chain must be reconciled BEFORE emitting, + // otherwise every inner identify is dropped by the excluded EVM chain and + // the Privy identity is silently lost. + await formo.identify(user, { privy: true, activeAddress: SOL }); + + expect(events.map((e) => e.address)).to.include(SOL); + for (const e of events) expect(e.userId).to.equal(DID); + }); + + it("reconciles chain via the direct identifyPrivyUser() entry point too", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + const SOL = "So11111111111111111111111111111111111111112"; + await formo.connect({ chainId: 1, address: EXTERNAL }); + expect(formo.currentChainId).to.equal(1); + + const user: PrivyUser = { + id: DID, + linkedAccounts: [ + { type: "wallet", address: SOL, walletClientType: "phantom", chainType: "solana" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + ], + }; + + // Called directly (not via the flag form) — must reconcile chain the same way. + await identifyPrivyUser(formo, user, { activeAddress: SOL }); + + expect(formo.currentAddress).to.equal(SOL); + expect(formo.currentChainId).to.be.oneOf([undefined, null]); + }); + + it("does not let a non-active linked wallet hijack currentAddress after a real connect", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + // Simulate the user having connected an external wallet first. + await formo.identify({ address: EXTERNAL, rdns: "io.metamask" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + + // Now identify the Privy user, telling the SDK EXTERNAL is the active one. + const user: PrivyUser = { + id: DID, + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + await identifyPrivyUser(formo, user, { activeAddress: EXTERNAL }); + + // The active wallet (EXTERNAL) is identified last, so even though the + // embedded wallet is also identified during the call, attribution ends up + // on EXTERNAL rather than an arbitrary linked wallet. + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("dedups a repeated call but re-emits when a DID is attached to an anonymous wallet", async () => { + const formo = await makeAnalytics(); + const events = captureIdentifies(formo); + + // Wallet identified anonymously first (e.g. on connect). + await formo.identify({ address: EMBEDDED, rdns: "io.metamask" }); + expect(events).to.have.length(1); + + const user: PrivyUser = { + id: DID, + linkedAccounts: [{ type: "wallet", address: EMBEDDED, walletClientType: "privy" }], + }; + + // Attaching the DID re-emits (userId folded into the dedup key). + await identifyPrivyUser(formo, user); + expect(events).to.have.length(2); + expect(events[1].userId).to.equal(DID); + + // Running the exact same identify again is deduped — no new event. + await identifyPrivyUser(formo, user); + expect(events).to.have.length(2); + }); +}); diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index 57b7cad..3b949f7 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -2,8 +2,44 @@ import { describe, it } from "mocha"; import { expect } from "chai"; import { parsePrivyProperties, + identifyPrivyUser, PrivyUser, } from "../../src/privy"; +import type { IFormoAnalytics } from "../../src/types"; + +interface RecordedIdentify { + params: { + address: string; + userId?: string; + setActive?: boolean; + rdns?: string; + providerName?: string; + }; + properties?: Record; +} + +/** The address of the wallet promoted to active (setActive:true), if any. */ +function activeAddressOf(calls: RecordedIdentify[]): string | undefined { + return calls.find((c) => c.params.setActive === true)?.params.address; +} + +/** + * Minimal IFormoAnalytics stub that records identify() calls so we can assert + * ordering, attribution flags, and forwarded per-wallet metadata. + */ +function makeRecorder(): { analytics: IFormoAnalytics; calls: RecordedIdentify[] } { + const calls: RecordedIdentify[] = []; + const analytics = { + identify: async (params: any, properties: any) => { + calls.push({ params, properties }); + }, + } as unknown as IFormoAnalytics; + return { analytics, calls }; +} + +const EMBEDDED = "0x1111111111111111111111111111111111111111"; +const EXTERNAL = "0x2222222222222222222222222222222222222222"; +const EXTERNAL_2 = "0x3333333333333333333333333333333333333333"; describe("Privy Utilities", () => { describe("parsePrivyProperties", () => { @@ -324,4 +360,246 @@ describe("Privy Utilities", () => { }); }); }); + + describe("identifyPrivyUser", () => { + it("identifies every linked wallet tagged with the Privy DID", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + email: { address: "user@example.com" }, + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy", chainType: "ethereum" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + expect(calls).to.have.length(2); + for (const call of calls) { + expect(call.params.userId).to.equal("did:privy:abc123"); + } + }); + + it("forwards per-wallet metadata and shared profile properties", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + email: { address: "user@example.com" }, + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy", chainType: "ethereum" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + expect(calls).to.have.length(1); + const props = calls[0].properties!; + // per-wallet metadata + expect(props.wallet_client).to.equal("privy"); + expect(props.chain_type).to.equal("ethereum"); + expect(props.is_embedded).to.equal(true); + // shared profile properties + expect(props.privyDid).to.equal("did:privy:abc123"); + expect(props.email).to.equal("user@example.com"); + }); + + it("omits wallet_client/chain_type when absent but always sends is_embedded", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [{ type: "wallet", address: EXTERNAL }], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + const props = calls[0].properties!; + expect(props).to.not.have.property("wallet_client"); + expect(props).to.not.have.property("chain_type"); + expect(props.is_embedded).to.equal(false); + }); + + it("promotes the provided active wallet (setActive) and records the rest for clustering", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL_2, walletClientType: "rainbow" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user, { activeAddress: EXTERNAL }); + + // Every wallet is identified for clustering... + expect(calls.map((c) => c.params.address)).to.have.members([ + EMBEDDED, + EXTERNAL, + EXTERNAL_2, + ]); + // ...but only the active wallet is promoted to attribution. + expect(activeAddressOf(calls)).to.equal(EXTERNAL); + for (const c of calls) { + expect(c.params.setActive).to.equal(c.params.address === EXTERNAL); + } + }); + + it("matches the active wallet case-insensitively (EVM)", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user, { + activeAddress: EMBEDDED.toUpperCase(), + }); + + expect(activeAddressOf(calls)).to.equal(EMBEDDED); + }); + + it("falls back to the last external wallet when no active address or user.wallet", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + // Heuristic: embedded wallets deprioritized, so the external wallet is active. + expect(activeAddressOf(calls)).to.equal(EXTERNAL); + }); + + it("defaults the active wallet to user.wallet when activeAddress is omitted", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + // Privy's surfaced primary wallet is the embedded one. + wallet: { address: EMBEDDED }, + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + // user.wallet (EMBEDDED) is promoted, overriding the external-wallet heuristic. + expect(activeAddressOf(calls)).to.equal(EMBEDDED); + }); + + it("lets an explicit activeAddress override user.wallet", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + wallet: { address: EMBEDDED }, + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user, { activeAddress: EXTERNAL }); + + expect(activeAddressOf(calls)).to.equal(EXTERNAL); + }); + + it("compares Solana active addresses case-sensitively (no false match)", async () => { + // A Base58 Solana address (case-sensitive), lower-cased into a variant + // that is NOT the same address. + const SOL = "So11111111111111111111111111111111111111112"; + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: SOL, walletClientType: "privy", chainType: "solana" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + ], + }; + + // Wrong-case Solana address is explicitly provided but doesn't match, so + // no wallet is promoted (an explicit address is matched strictly, never + // guessed) — while both wallets are still recorded for clustering. + const miss = makeRecorder(); + await identifyPrivyUser(miss.analytics, user, { + activeAddress: SOL.toLowerCase(), + }); + expect(activeAddressOf(miss.calls)).to.equal(undefined); + expect(miss.calls).to.have.length(2); + + // Exact-case Solana address IS accepted → promoted. + const hit = makeRecorder(); + await identifyPrivyUser(hit.analytics, user, { activeAddress: SOL }); + expect(activeAddressOf(hit.calls)).to.equal(SOL); + }); + + it("merges options.properties into every identify call", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user, { + properties: { plan: "pro" }, + }); + + expect(calls).to.have.length(2); + for (const call of calls) { + expect(call.properties!.plan).to.equal("pro"); + } + }); + + it("does nothing when the user has no linked wallets", async () => { + const user: PrivyUser = { + id: "did:privy:abc123", + email: { address: "user@example.com" }, + linkedAccounts: [{ type: "email", address: "user@example.com" }], + }; + const { analytics, calls } = makeRecorder(); + + await identifyPrivyUser(analytics, user); + + expect(calls).to.have.length(0); + }); + + it("emits nothing and does not reconcile chain when tracking is suppressed", async () => { + const calls: RecordedIdentify[] = []; + let chainReconciled = false; + const analytics = { + identify: async (params: any, properties: any) => { + calls.push({ params, properties }); + }, + syncPrivyActiveChain: () => { + chainReconciled = true; + }, + isTrackingSuppressed: () => true, + } as unknown as IFormoAnalytics; + + const user: PrivyUser = { + id: "did:privy:abc123", + linkedAccounts: [ + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + + const result = await identifyPrivyUser(analytics, user); + + // No identifies emitted, and — critically — no chain reconciliation, which + // would otherwise clear an excluded chain id while no identify runs. + expect(calls).to.have.length(0); + expect(chainReconciled).to.equal(false); + expect(result).to.equal(undefined); + }); + }); }); diff --git a/test/session/dedup.spec.ts b/test/session/dedup.spec.ts new file mode 100644 index 0000000..e95d21f --- /dev/null +++ b/test/session/dedup.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, beforeEach, afterEach } from "mocha"; +import { expect } from "chai"; +import { JSDOM } from "jsdom"; +import { FormoAnalyticsSession } from "../../src/session"; +import { initStorageManager } from "../../src/storage"; + +/** + * Session identify deduplication with a user ID folded into the key. + * + * Verifies that attaching a user ID (e.g. a Privy DID) to an already-identified + * wallet is treated as a new identity and re-emits, while repeated identifies + * with the same (address, rdns, userId) are still deduped. + */ +describe("Session identify dedup with userId", () => { + let jsdom: JSDOM; + + const ADDRESS = "0x1111111111111111111111111111111111111111"; + const RDNS = "io.metamask"; + const DID = "did:privy:abc123"; + + beforeEach(() => { + jsdom = new JSDOM("", { + url: "https://example.com", + }); + Object.defineProperty(global, "window", { + value: jsdom.window, writable: true, configurable: true, + }); + Object.defineProperty(global, "document", { + value: jsdom.window.document, writable: true, configurable: true, + }); + Object.defineProperty(global, "navigator", { + value: jsdom.window.navigator, writable: true, configurable: true, + }); + initStorageManager("test-write-key"); + }); + + afterEach(() => { + delete (global as any).window; + delete (global as any).document; + delete (global as any).navigator; + }); + + it("re-emits when a userId is attached to an already-identified wallet", () => { + const session = new FormoAnalyticsSession(); + + // First identify: anonymous (no userId). + expect(session.isWalletIdentified(ADDRESS, RDNS)).to.equal(false); + session.markWalletIdentified(ADDRESS, RDNS); + expect(session.isWalletIdentified(ADDRESS, RDNS)).to.equal(true); + + // Same wallet, now with a Privy DID: must NOT be considered already + // identified, so a fresh identify event is emitted. + expect(session.isWalletIdentified(ADDRESS, RDNS, DID)).to.equal(false); + session.markWalletIdentified(ADDRESS, RDNS, DID); + expect(session.isWalletIdentified(ADDRESS, RDNS, DID)).to.equal(true); + }); + + it("dedups repeated identifies with the same address + userId", () => { + const session = new FormoAnalyticsSession(); + + expect(session.isWalletIdentified(ADDRESS, "", DID)).to.equal(false); + session.markWalletIdentified(ADDRESS, "", DID); + // A second identify with the same userId is a duplicate. + expect(session.isWalletIdentified(ADDRESS, "", DID)).to.equal(true); + }); + + it("treats different userIds on the same wallet as distinct identities", () => { + const session = new FormoAnalyticsSession(); + const otherDid = "did:privy:xyz789"; + + session.markWalletIdentified(ADDRESS, "", DID); + expect(session.isWalletIdentified(ADDRESS, "", DID)).to.equal(true); + // Switching to a different user on the same wallet re-emits. + expect(session.isWalletIdentified(ADDRESS, "", otherDid)).to.equal(false); + }); + + it("handles userIds containing a comma (the cookie delimiter) without breaking dedup", () => { + const session = new FormoAnalyticsSession(); + // An external userId that contains the comma used to join cookie entries. + const commaUser = "external,user,42"; + + expect(session.isWalletIdentified(ADDRESS, "", commaUser)).to.equal(false); + session.markWalletIdentified(ADDRESS, "", commaUser); + // Encoded before storage, so the raw comma can't corrupt the key: the same + // (wallet, user) is now recognized as already identified rather than + // re-emitting on every call. + expect(session.isWalletIdentified(ADDRESS, "", commaUser)).to.equal(true); + }); + + it("retains every identity for a many-wallet user (size-bounded, not a 20-entry cap)", () => { + const session = new FormoAnalyticsSession(); + const did = "did:privy:manywallets"; + const addrs = Array.from( + { length: 40 }, + (_, i) => "0x" + (i + 1).toString(16).padStart(40, "0") + ); + + for (const a of addrs) session.markWalletIdentified(a, "", did); + + // All 40 remain recognized — well past the old fixed 20-entry limit, which + // would have evicted the first 20 and re-emitted them on a later sync. + for (const a of addrs) { + expect(session.isWalletIdentified(a, "", did)).to.equal(true); + } + }); + + it("does not collide a userId with a provider RDNS of the same value", () => { + const session = new FormoAnalyticsSession(); + // Anonymous identify with rdns = "io.metamask". + session.markWalletIdentified(ADDRESS, "io.metamask"); + expect(session.isWalletIdentified(ADDRESS, "io.metamask")).to.equal(true); + // A later identify whose userId equals that RDNS string is a distinct tuple + // (address + empty rdns + userId), so it must NOT be considered already + // identified — its identity-link event still emits. + expect(session.isWalletIdentified(ADDRESS, "", "io.metamask")).to.equal(false); + }); + + it("stays backward compatible: omitting userId matches legacy keys", () => { + const session = new FormoAnalyticsSession(); + + // Legacy call path (address + rdns, no userId) is unchanged. + session.markWalletIdentified(ADDRESS, RDNS); + expect(session.isWalletIdentified(ADDRESS, RDNS)).to.equal(true); + // address-only fallback (no rdns, no userId) is also unchanged. + session.markWalletIdentified(ADDRESS, ""); + expect(session.isWalletIdentified(ADDRESS, "")).to.equal(true); + }); +});