From 076ef6d0d36fef304c4ed69bedd482dc2bc1203e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 08:42:15 +0000 Subject: [PATCH 01/14] feat(privy): one-liner identifyPrivyUser to cluster a Privy user's wallets Make tagging every wallet linked to a Privy user under that user's DID a one-liner, so the 8-wallet case clusters into a single user end to end. - identifyPrivyUser(analytics, user, options?): new exported helper next to parsePrivyProperties. Loops the user's linked wallets and calls identify() for each, forwarding the previously-dropped per-wallet metadata (wallet_client, chain_type, is_embedded) alongside the shared profile properties, all tagged with userId: user.id. - Event attribution: only the active/connected wallet is promoted to the SDK's current address. identify() gains an optional setCurrentAddress flag (default true); non-active linked wallets are identified with it false so they no longer hijack which wallet later events attribute to. The active wallet is identified last; callers pass activeAddress (from useWallets()), otherwise the SDK falls back to embedded-first, attribute-last ordering. - Dedup: fold userId into the session identify key (address[:rdns][:userId]), so attaching a DID to a wallet that was already identified anonymously re-emits, while repeats with the same userId stay deduped. Backward compatible when userId is absent. - useIdentifyPrivyUser(user, options?): optional React binding that re-runs identifyPrivyUser when the Privy DID or linked-wallet set changes, covering login, linkWallet, and unlinkWallet. Exported from the React entry only so the core entry stays React-free. - Docs: new docs/PRIVY_INTEGRATION.md centered on the one-liner, with parsePrivyProperties kept documented for advanced use; README pointer added. - Tests: unit coverage for identifyPrivyUser ordering/metadata/attribution and userId-aware dedup, plus an end-to-end integration spec driving the real identify() (no hijack after a real connect; re-emit on DID attach). Verified against Privy docs: linked wallet addresses are available on the frontend via usePrivy() user.linkedAccounts; the active wallet comes from useWallets() (wallets[0]). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- README.md | 8 + docs/PRIVY_INTEGRATION.md | 220 ++++++++++++++++++ src/FormoAnalytics.ts | 40 ++-- src/core.ts | 3 +- src/index.ts | 5 + src/privy/index.ts | 10 +- src/privy/react.ts | 88 +++++++ src/privy/utils.ts | 142 +++++++++++ src/session/index.ts | 50 +++- src/types/base.ts | 10 + .../identifyPrivyUser.integration.spec.ts | 180 ++++++++++++++ test/privy/privy.spec.ts | 184 +++++++++++++++ test/session/dedup.spec.ts | 87 +++++++ 13 files changed, 997 insertions(+), 30 deletions(-) create mode 100644 docs/PRIVY_INTEGRATION.md create mode 100644 src/privy/react.ts create mode 100644 test/privy/identifyPrivyUser.integration.spec.ts create mode 100644 test/session/dedup.spec.ts 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..6b6c75c --- /dev/null +++ b/docs/PRIVY_INTEGRATION.md @@ -0,0 +1,220 @@ +# 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 +``` + +This is the **one-liner** replacement for hand-rolling an `identify()` loop. + +## Quick start (React) + +Drop `useIdentifyPrivyUser` into any component rendered under +`FormoAnalyticsProvider` and hand it the Privy `user`. It keeps Formo's identity +in sync automatically — on login, on `linkWallet`, and on `unlinkWallet`. + +```tsx +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { useIdentifyPrivyUser } from "@formo/analytics"; + +function AnalyticsIdentity() { + const { user, authenticated, ready } = usePrivy(); + const { wallets } = useWallets(); + + useIdentifyPrivyUser(user, { + enabled: ready && authenticated, + // The currently-connected wallet — identified last so event attribution + // stays on the wallet the user is actually transacting with. + activeAddress: wallets[0]?.address, + }); + + return null; +} +``` + +That's it. Every wallet linked to the Privy user is identified under the user's +DID, with per-wallet metadata forwarded, and event attribution pinned to the +active wallet. + +> The hook is keyed on a stable signature of the DID + the set of linked wallet +> addresses (not on the `user` object reference, which Privy re-creates every +> render), so it only re-runs when something meaningful changes. + +## Framework-agnostic usage + +Not using React (or want to call it imperatively)? Use `identifyPrivyUser` +directly — it works with the `core` entry too. + +```ts +import { identifyPrivyUser } from "@formo/analytics"; + +const { user } = usePrivy(); +if (user) { + await identifyPrivyUser(formo, user, { + activeAddress: connectedWallet?.address, // optional but recommended + }); +} +``` + +### 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, setCurrentAddress }, + { + ...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 + } +); +``` + +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. + +## 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. Since `identify()` also updates the SDK's +"current address" (the wallet later events are attributed to), naively looping +over every linked wallet would leave attribution on whichever wallet happened to +be identified last. + +`identifyPrivyUser` handles this for you: + +- **When you pass `activeAddress`** (recommended — from `useWallets()` or your + wagmi account), that wallet is identified **last** and is the **only** one + promoted to the current address. Every other wallet is identified with + `setCurrentAddress: false`, so it's recorded for clustering without hijacking + attribution. +- **When you don't**, the SDK falls back to a best-effort order: embedded + (Privy) wallets first, external wallets last, and attribution goes to the last + external wallet. + +Prefer passing `activeAddress` whenever you can derive it. + +## 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 the React hook, this means login, `linkWallet`, and `unlinkWallet` +each produce exactly the identify events you'd expect and nothing more. + +## 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. + +## 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?** +The React hook re-runs whenever the linked-wallet set changes, so newly linked +wallets are identified automatically. With the imperative helper, just call +`identifyPrivyUser` again after a `linkWallet` succeeds. + +**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/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 211fe7d..55b31a2 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -690,15 +690,11 @@ export class FormoAnalytics implements IFormoAnalytics { * // Basic identify * formo.identify({ address: '0x...', userId: 'user123' }); * - * // With Privy user - * import { parsePrivyProperties } from '@formo/analytics'; + * // With a Privy user, prefer the one-liner helper which forwards + * // per-wallet metadata and keeps event attribution on the active wallet: + * import { identifyPrivyUser } from '@formo/analytics'; * 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) await identifyPrivyUser(formo, user); * ``` */ async identify( @@ -707,6 +703,7 @@ export class FormoAnalytics implements IFormoAnalytics { providerName?: string; userId?: string; rdns?: string; + setCurrentAddress?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, @@ -790,7 +787,7 @@ export class FormoAnalytics implements IFormoAnalytics { return; } - const { address, providerName, userId, rdns } = params; + const { address, providerName, userId, rdns, setCurrentAddress } = params; // Runtime validation: address is required if (!address) { @@ -801,13 +798,18 @@ 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; } + // Only promote this wallet to the current/active address when the caller + // wants it (default). Passing `setCurrentAddress: false` records the + // identity for clustering without changing which wallet later events are + // attributed to — used when identifying non-active linked wallets. + if (setCurrentAddress !== false) { + this.currentAddress = validAddress; + this.persistActiveWallet(); + } if (userId) { this.currentUserId = userId; const domain = getIdentityCookieDomain(this.crossSubdomainCookies); @@ -818,8 +820,14 @@ export class FormoAnalytics implements IFormoAnalytics { }); } - // 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 +849,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, 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/index.ts b/src/index.ts index fd1f0b0..bcb5cf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,9 @@ import { formofy } from "./initialization"; export * from "./core"; export * from "./FormoAnalyticsProvider"; +// React-only Privy binding. Lives in the root entry (not `core`) because it +// imports React. +export { useIdentifyPrivyUser } from "./privy/react"; +export type { UseIdentifyPrivyUserOptions } from "./privy/react"; + if (typeof window !== "undefined") window.formofy = formofy; diff --git a/src/privy/index.ts b/src/privy/index.ts index 4590717..3bb6de6 100644 --- a/src/privy/index.ts +++ b/src/privy/index.ts @@ -2,10 +2,16 @@ * Privy integration module * * Provides utilities for enriching wallet profiles with Privy user data. - * This module exports the property extraction utility and related types. + * This module exports the property extraction utility, the one-liner + * `identifyPrivyUser` helper, and related types. + * + * Note: the React binding (`useIdentifyPrivyUser`) is intentionally NOT + * re-exported here so this module stays free of a React dependency and can be + * used from the React-free `core` entry. Import the hook from the package root. */ -export { parsePrivyProperties } from "./utils"; +export { parsePrivyProperties, identifyPrivyUser } from "./utils"; +export type { IdentifyPrivyUserOptions } from "./utils"; export type { PrivyUser, PrivyLinkedAccount, diff --git a/src/privy/react.ts b/src/privy/react.ts new file mode 100644 index 0000000..cfe0b39 --- /dev/null +++ b/src/privy/react.ts @@ -0,0 +1,88 @@ +/** + * Optional React binding for Privy identity. + * + * This module imports React and therefore lives outside the React-free `core` + * entry point. Import it from the package root (`@formo/analytics`). + */ + +import { useEffect } from "react"; +import { useFormo } from "../FormoAnalyticsProvider"; +import { logger } from "../logger"; +import { identifyPrivyUser, IdentifyPrivyUserOptions } from "./utils"; +import { PrivyUser } from "./types"; + +export interface UseIdentifyPrivyUserOptions extends IdentifyPrivyUserOptions { + /** + * Gate the effect. Pass Privy's `authenticated` (or `ready && authenticated`) + * so nothing is identified while auth is still resolving or the user is + * logged out. Defaults to `true`. + */ + enabled?: boolean; +} + +/** + * Keep Formo's identity in sync with the Privy `usePrivy()` user object. + * + * Drop this hook into any component under `FormoAnalyticsProvider` and pass it + * the Privy `user`. Whenever the user's Privy DID or the set of linked wallet + * addresses changes — covering login, `linkWallet` success, and `unlinkWallet` + * — it re-runs {@link identifyPrivyUser}, so every wallet stays tagged with the + * user's DID for server-side clustering. + * + * The effect is keyed on a stable signature of the DID + sorted linked-wallet + * addresses + active address, not on the `user` object reference (which Privy + * re-creates on every render), so it fires only on meaningful changes. + * + * @example + * ```tsx + * import { usePrivy, useWallets } from '@privy-io/react-auth'; + * import { useIdentifyPrivyUser } from '@formo/analytics'; + * + * function AnalyticsIdentity() { + * const { user, authenticated, ready } = usePrivy(); + * const { wallets } = useWallets(); + * useIdentifyPrivyUser(user, { + * enabled: ready && authenticated, + * activeAddress: wallets[0]?.address, + * }); + * return null; + * } + * ``` + */ +export function useIdentifyPrivyUser( + user: PrivyUser | null | undefined, + options: UseIdentifyPrivyUserOptions = {} +): void { + const analytics = useFormo(); + const { enabled = true, activeAddress, properties } = options; + + // A stable, order-independent signature of the linked wallet set. Used so the + // effect re-runs when a wallet is linked or unlinked, but not on every render. + const walletSignature = user + ? (user.linkedAccounts || []) + .filter( + (a) => + (a.type === "wallet" || a.type === "smart_wallet") && !!a.address + ) + .map((a) => (a.address as string).toLowerCase()) + .sort() + .join(",") + : ""; + + // Empty signature => nothing to do (logged out or disabled). + const signature = + user && enabled + ? [user.id, activeAddress?.toLowerCase() ?? "", walletSignature].join("|") + : ""; + + useEffect(() => { + if (!signature || !user) return; + identifyPrivyUser(analytics, user, { activeAddress, properties }).catch( + (err) => logger.error("useIdentifyPrivyUser: identify failed", err) + ); + // Keyed on the derived `signature` string rather than the `user`/`options` + // object references, which change identity on every render. `user`, + // `activeAddress`, and `properties` are read fresh inside the effect and + // are consistent with the signature that triggered it. + }, [analytics, signature]); +} diff --git a/src/privy/utils.ts b/src/privy/utils.ts index a5d727f..6afd6ec 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -7,6 +7,8 @@ import { PrivyUser, PrivyWalletInfo, } from "./types"; +import { IFormoAnalytics } from "../types/base"; +import { IFormoEventProperties } from "../types/events"; /** * Extract profile properties and wallet addresses from a Privy user object. @@ -14,6 +16,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` * @@ -218,3 +225,138 @@ export function parsePrivyProperties(user: PrivyUser): { return { properties, wallets }; } + +/** + * Options for {@link identifyPrivyUser}. + */ +export interface IdentifyPrivyUserOptions { + /** + * Address of the wallet the user is actively transacting with — typically + * the connected wallet from Privy's `useWallets()` (`wallets[0]?.address`) + * or your wagmi account. + * + * A Privy user's `linkedAccounts` lists every wallet they have ever linked, + * not which one is currently connected, so the SDK cannot tell them apart on + * its own. When you provide `activeAddress`, that wallet is identified last + * and becomes the address later events are attributed to, while the other + * linked wallets are recorded for identity clustering without taking over + * attribution. + * + * When omitted, the SDK falls back to identifying embedded (Privy) wallets + * first and attributing to the last external wallet — a best-effort guess. + */ + 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`). + */ + 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/connected wallet (see + * {@link IdentifyPrivyUserOptions.activeAddress}) is promoted to the SDK's + * current address; the rest are identified with `setCurrentAddress: false` so + * they do not hijack which wallet subsequent events are attributed to. + * + * All linked wallet addresses used here come from `user.linkedAccounts`, which + * is fully available on the frontend from Privy's `usePrivy()` hook. + * + * @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) { + * await identifyPrivyUser(formo, user, { + * activeAddress: wallets[0]?.address, + * }); + * } + * ``` + */ +export async function identifyPrivyUser( + analytics: IFormoAnalytics, + user: PrivyUser, + options: IdentifyPrivyUserOptions = {} +): Promise { + if (!analytics || !user) return; + + 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. + if (wallets.length === 0) return; + + const baseProperties: IFormoEventProperties = { + ...properties, + ...options.properties, + }; + + // Resolve the active/connected wallet (case-insensitive). Only honor it if it + // actually matches one of the linked wallets. + const activeAddress = options.activeAddress?.toLowerCase(); + const hasActive = + !!activeAddress && + wallets.some((w) => w.address.toLowerCase() === activeAddress); + + // Order embedded (Privy) wallets first and external wallets last. When we + // know the active wallet, move it to the very end. identify() is called in + // order, so the last wallet flagged with setCurrentAddress wins attribution. + const embedded = wallets.filter((w) => w.isEmbedded); + const external = wallets.filter((w) => !w.isEmbedded); + let ordered: PrivyWalletInfo[] = [...embedded, ...external]; + if (hasActive) { + const active = wallets.find( + (w) => w.address.toLowerCase() === activeAddress + )!; + ordered = [ + ...ordered.filter((w) => w.address.toLowerCase() !== activeAddress), + active, + ]; + } + + const lastIndex = ordered.length - 1; + + for (let i = 0; i < ordered.length; i++) { + const wallet = ordered[i]; + + // The wallet that should own event attribution: the resolved active wallet + // if known, otherwise the last wallet in embedded-first order (a best-effort + // guess that prefers an external wallet over an embedded one). + const isAttributionWallet = hasActive + ? wallet.address.toLowerCase() === activeAddress + : i === lastIndex; + + 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 analytics.identify( + { + address: wallet.address, + userId: user.id, + setCurrentAddress: isAttributionWallet, + }, + walletProperties + ); + } +} diff --git a/src/session/index.ts b/src/session/index.ts index 38d0112..fd142dc 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; } /** @@ -62,15 +67,30 @@ const MAX_SESSION_ENTRIES = 20; 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 { + private generateIdentificationKey( + address: string, + rdns: string, + userId?: string + ): string { // If rdns is missing, use address-only key as fallback for empty identifies - return rdns ? `${address}:${rdns}` : address; + const parts = [address]; + if (rdns) parts.push(rdns); + if (userId) parts.push(userId); + return parts.join(":"); } /** @@ -113,8 +133,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,8 +159,12 @@ 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); diff --git a/src/types/base.ts b/src/types/base.ts index bd152e9..552ef01 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -95,6 +95,16 @@ export interface IFormoAnalytics { providerName?: string; userId?: string; rdns?: string; + /** + * Whether this identify should set the SDK's current wallet address — + * the address later events are attributed to. Defaults to `true`. + * + * Set to `false` when identifying a linked-but-not-active wallet (e.g. + * one of several wallets behind a single Privy user) so it is recorded + * for identity clustering without hijacking event attribution away from + * the wallet the user is actually transacting with. + */ + setCurrentAddress?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts new file mode 100644 index 0000000..f858d32 --- /dev/null +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -0,0 +1,180 @@ +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(): 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 }, + }); + } + + /** 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("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 embedded wallet was identified with setCurrentAddress:false, so the + // active external wallet still owns attribution. + 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..49d2246 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -2,8 +2,39 @@ 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; + setCurrentAddress?: boolean; + rdns?: string; + providerName?: string; + }; + properties?: Record; +} + +/** + * 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 +355,157 @@ 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("attributes to the provided active wallet and identifies it last", 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 }); + + // Active wallet is identified last... + expect(calls[calls.length - 1].params.address).to.equal(EXTERNAL); + // ...and is the only one that sets the current address. + for (const call of calls) { + const isActive = call.params.address === EXTERNAL; + expect(call.params.setCurrentAddress).to.equal(isActive); + } + }); + + it("matches the active wallet case-insensitively", 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(), + }); + + const active = calls.find((c) => c.params.setCurrentAddress === true); + expect(active?.params.address).to.equal(EMBEDDED); + expect(calls[calls.length - 1].params.address).to.equal(EMBEDDED); + }); + + it("falls back to embedded-first order and attributes to the last external 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); + + // Embedded wallet is identified first... + expect(calls[0].params.address).to.equal(EMBEDDED); + expect(calls[0].params.setCurrentAddress).to.equal(false); + // ...and the external wallet is identified last and owns attribution. + expect(calls[1].params.address).to.equal(EXTERNAL); + expect(calls[1].params.setCurrentAddress).to.equal(true); + }); + + 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); + }); + }); }); diff --git a/test/session/dedup.spec.ts b/test/session/dedup.spec.ts new file mode 100644 index 0000000..aed014d --- /dev/null +++ b/test/session/dedup.spec.ts @@ -0,0 +1,87 @@ +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("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); + }); +}); From 5d39f800f49a401025ee05dcefc865e12ebe680d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:23:23 +0000 Subject: [PATCH 02/14] =?UTF-8?q?refactor(privy):=20address=20API=20review?= =?UTF-8?q?=20=E2=80=94=20safer=20setActive=20contract,=20smarter=20defaul?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the identifyPrivyUser review. Tightens the attribution contract and the ergonomics; documents the deliberate scope boundaries. - setActive contract (was setCurrentAddress): the flag now gates the *whole* active identity — both currentAddress AND currentUserId/SESSION_USER_ID_KEY. Previously setCurrentAddress:false still let a non-active identify repoint the global user ID, so a caller could end up with the active wallet paired with a different, non-active user. Renamed to reflect it protects active attribution state, not just the address. - identifyPrivyUser: activeAddress now falls back to the SDK's existing currentAddress (e.g. from a prior wagmi connect) before the embedded-first heuristic, so callers that already track connects can drop the option. Exposed IFormoAnalytics.currentAddress (readonly) to support this. Walletless users are now logged instead of dropped silently. - Dedup storage: identified entries get their own cap (30, up from the shared 20), sized for many-wallet Privy users (up to ~2N entries across anon + DID) while staying under the ~4KB per-cookie limit; documented the FIFO-eviction consequence. - React hook + options: documented that options.properties is captured at first identify (session dedup means property-only changes don't re-emit) and that unlink is additive server-side (no unlink event). Extracted a shared isPrivyWalletAccount predicate so the hook's re-run key and parsePrivyProperties can't drift. - Tests: added integration coverage for the currentAddress attribution fallback and for setActive:false leaving the active (address, userId) pair untouched. Deferred (need a backend/event contract, out of scope for the one-liner): walletless userId-keyed identify, and an explicit wallet-unlink event. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 58 ++++++++++++--- src/FormoAnalytics.ts | 37 ++++++---- src/privy/react.ts | 19 +++-- src/privy/utils.ts | 74 +++++++++++++++---- src/session/index.ts | 17 ++++- src/types/base.ts | 20 +++-- .../identifyPrivyUser.integration.spec.ts | 44 ++++++++++- test/privy/privy.spec.ts | 10 +-- 8 files changed, 216 insertions(+), 63 deletions(-) diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index 6b6c75c..19e058e 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -68,7 +68,7 @@ import { identifyPrivyUser } from "@formo/analytics"; const { user } = usePrivy(); if (user) { await identifyPrivyUser(formo, user, { - activeAddress: connectedWallet?.address, // optional but recommended + activeAddress: connectedWallet?.address, // optional; see "attribution" below }); } ``` @@ -92,7 +92,7 @@ For each linked wallet, `identifyPrivyUser` calls: ```ts formo.identify( - { address, userId: user.id, setCurrentAddress }, + { address, userId: user.id, setActive }, { ...profileProperties, // email, socials, privyDid, privyCreatedAt, … wallet_client, // e.g. "metamask", "privy", "rainbow" @@ -113,6 +113,13 @@ 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 @@ -123,16 +130,21 @@ be identified last. `identifyPrivyUser` handles this for you: -- **When you pass `activeAddress`** (recommended — from `useWallets()` or your - wagmi account), that wallet is identified **last** and is the **only** one - promoted to the current address. Every other wallet is identified with - `setCurrentAddress: false`, so it's recorded for clustering without hijacking - attribution. -- **When you don't**, the SDK falls back to a best-effort order: embedded - (Privy) wallets first, external wallets last, and attribution goes to the last +- **When you pass `activeAddress`** (from `useWallets()` or your wagmi account), + that wallet is identified **last** and is the **only** one promoted to the + active identity. Every other wallet is identified with `setActive: false`, so + it's recorded for clustering without hijacking attribution — and `setActive: + false` guards both the active address *and* the active user ID, so a non-active + wallet can never leave the SDK pairing your connected wallet with the wrong + user. +- **When you omit it**, the SDK first falls back to its own `currentAddress` (set + by a prior wagmi/EIP-1193 connect) — so if you already track connects, you + usually don't need to pass anything. Failing that, it uses a best-effort order: + embedded (Privy) wallets first, external wallets last, attributing to the last external wallet. -Prefer passing `activeAddress` whenever you can derive it. +Pass `activeAddress` when you have it and the SDK isn't already tracking the +connected wallet; otherwise the default is usually right. ## When identity re-emits @@ -145,8 +157,10 @@ Formo deduplicates identify events per session. The dedup key now includes the once the Privy DID is attached after login. - Switching Privy users on the same wallet re-emits under the new DID. -Combined with the React hook, this means login, `linkWallet`, and `unlinkWallet` -each produce exactly the identify events you'd expect and nothing more. +Combined with the React hook, login and `linkWallet` produce exactly the +identify events you'd expect and nothing more. `unlinkWallet` re-runs the hook +for the remaining wallets but emits no event of its own — see +[Limitations](#limitations--roadmap). ## Advanced: `parsePrivyProperties` @@ -173,6 +187,26 @@ 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.** The helper emits positive wallet↔user link events + only. When a wallet is unlinked in Privy the React hook 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 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 55b31a2..1bae495 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -703,7 +703,7 @@ export class FormoAnalytics implements IFormoAnalytics { providerName?: string; userId?: string; rdns?: string; - setCurrentAddress?: boolean; + setActive?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, @@ -787,7 +787,7 @@ export class FormoAnalytics implements IFormoAnalytics { return; } - const { address, providerName, userId, rdns, setCurrentAddress } = params; + const { address, providerName, userId, rdns, setActive } = params; // Runtime validation: address is required if (!address) { @@ -802,22 +802,27 @@ export class FormoAnalytics implements IFormoAnalytics { logger.warn?.("Invalid address provided to identify:", address); return; } - // Only promote this wallet to the current/active address when the caller - // wants it (default). Passing `setCurrentAddress: false` records the - // identity for clustering without changing which wallet later events are - // attributed to — used when identifying non-active linked wallets. - if (setCurrentAddress !== false) { + // Promote this wallet to the SDK's active identity — the (address, + // userId) pair later events are attributed to — unless the caller opts + // out with `setActive: false`. Both the active address AND the active + // user ID are gated together: a non-active identify records the + // wallet↔user link (event emission + session dedup below) for clustering + // without repointing attribution. This matters when identifying several + // linked wallets that share one user (e.g. a Privy DID): only the active + // wallet should own attribution, and it must not be left 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 } : {}), - }); + 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. The userId is diff --git a/src/privy/react.ts b/src/privy/react.ts index cfe0b39..1e6ecf6 100644 --- a/src/privy/react.ts +++ b/src/privy/react.ts @@ -8,7 +8,11 @@ import { useEffect } from "react"; import { useFormo } from "../FormoAnalyticsProvider"; import { logger } from "../logger"; -import { identifyPrivyUser, IdentifyPrivyUserOptions } from "./utils"; +import { + identifyPrivyUser, + isPrivyWalletAccount, + IdentifyPrivyUserOptions, +} from "./utils"; import { PrivyUser } from "./types"; export interface UseIdentifyPrivyUserOptions extends IdentifyPrivyUserOptions { @@ -32,6 +36,14 @@ export interface UseIdentifyPrivyUserOptions extends IdentifyPrivyUserOptions { * The effect is keyed on a stable signature of the DID + sorted linked-wallet * addresses + active address, not on the `user` object reference (which Privy * re-creates on every render), so it fires only on meaningful changes. + * `options.properties` is intentionally NOT part of the key: identify events + * are deduped per `(wallet, user)` per session, so property-only changes would + * not re-emit anyway — treat properties as identity metadata set at first + * identify (see {@link IdentifyPrivyUserOptions.properties}). + * + * Scope: this emits positive identify (wallet↔user link) events only. Unlinking + * a wallet re-runs the effect for the smaller set, but there is no SDK-level + * "unlink" event, so links are additive from the backend's perspective. * * @example * ```tsx @@ -60,10 +72,7 @@ export function useIdentifyPrivyUser( // effect re-runs when a wallet is linked or unlinked, but not on every render. const walletSignature = user ? (user.linkedAccounts || []) - .filter( - (a) => - (a.type === "wallet" || a.type === "smart_wallet") && !!a.address - ) + .filter(isPrivyWalletAccount) .map((a) => (a.address as string).toLowerCase()) .sort() .join(",") diff --git a/src/privy/utils.ts b/src/privy/utils.ts index 6afd6ec..bba13a7 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -3,12 +3,27 @@ */ 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. Shared by {@link parsePrivyProperties} and the + * React binding so the "which accounts are wallets" rule can't drift between + * them. + */ +export function isPrivyWalletAccount(account: PrivyLinkedAccount): boolean { + return ( + (account.type === "wallet" || account.type === "smart_wallet") && + !!account.address + ); +} /** * Extract profile properties and wallet addresses from a Privy user object. @@ -214,7 +229,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, @@ -237,13 +252,18 @@ export interface IdentifyPrivyUserOptions { * * A Privy user's `linkedAccounts` lists every wallet they have ever linked, * not which one is currently connected, so the SDK cannot tell them apart on - * its own. When you provide `activeAddress`, that wallet is identified last - * and becomes the address later events are attributed to, while the other - * linked wallets are recorded for identity clustering without taking over - * attribution. + * its own. When resolved, that wallet is identified last and becomes the + * address later events are attributed to, while the other linked wallets are + * recorded for identity clustering without taking over attribution. + * + * Resolution order: + * 1. this `activeAddress`, if it matches a linked wallet; + * 2. else the SDK's existing `currentAddress` (e.g. from a prior wagmi + * connect), if it matches a linked wallet; + * 3. else a best-effort guess: embedded (Privy) wallets first, attributing to + * the last external wallet. * - * When omitted, the SDK falls back to identifying embedded (Privy) wallets - * first and attributing to the last external wallet — a best-effort guess. + * So if you already track wallet connects, you can usually omit this. */ activeAddress?: string; @@ -251,6 +271,11 @@ export interface IdentifyPrivyUserOptions { * 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; } @@ -267,12 +292,16 @@ export interface IdentifyPrivyUserOptions { * * Attribution: only the active/connected wallet (see * {@link IdentifyPrivyUserOptions.activeAddress}) is promoted to the SDK's - * current address; the rest are identified with `setCurrentAddress: false` so - * they do not hijack which wallet subsequent events are attributed to. + * active identity; the rest are identified with `setActive: false` so they do + * not hijack which wallet subsequent events are attributed to. * * 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} @@ -284,6 +313,8 @@ export interface IdentifyPrivyUserOptions { * 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, * }); @@ -300,24 +331,35 @@ export async function identifyPrivyUser( 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. - if (wallets.length === 0) return; + // 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; + } const baseProperties: IFormoEventProperties = { ...properties, ...options.properties, }; - // Resolve the active/connected wallet (case-insensitive). Only honor it if it - // actually matches one of the linked wallets. - const activeAddress = options.activeAddress?.toLowerCase(); + // Resolve the active/connected wallet (case-insensitive). Fall back to the + // SDK's existing currentAddress (e.g. from a prior wagmi connect) so callers + // that already track connects don't have to pass activeAddress. Only honored + // if it actually matches one of the linked wallets. + const activeAddress = ( + options.activeAddress ?? analytics.currentAddress + )?.toLowerCase(); const hasActive = !!activeAddress && wallets.some((w) => w.address.toLowerCase() === activeAddress); // Order embedded (Privy) wallets first and external wallets last. When we // know the active wallet, move it to the very end. identify() is called in - // order, so the last wallet flagged with setCurrentAddress wins attribution. + // order, so the last wallet flagged with setActive wins attribution. const embedded = wallets.filter((w) => w.isEmbedded); const external = wallets.filter((w) => !w.isEmbedded); let ordered: PrivyWalletInfo[] = [...embedded, ...external]; @@ -354,7 +396,7 @@ export async function identifyPrivyUser( { address: wallet.address, userId: user.id, - setCurrentAddress: isAttributionWallet, + setActive: isAttributionWallet, }, walletProperties ); diff --git a/src/session/index.ts b/src/session/index.ts index fd142dc..2b26d4d 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -64,6 +64,19 @@ export interface IFormoAnalyticsSession { */ const MAX_SESSION_ENTRIES = 20; +/** + * Cap for identified wallet-address entries. Higher than {@link + * MAX_SESSION_ENTRIES} because a single user can identify many wallets in one + * session — a Privy user with N linked wallets consumes up to 2N entries when + * each is seen anonymously and then again with a DID (plus per-provider RDNS + * variants). The cap is deliberately bounded, not unlimited: entries are stored + * comma-joined in a cookie, and a worst-case key (`address:rdns:did`, ~110 + * bytes) times this cap stays under the ~4KB per-cookie browser limit. When the + * cap is exceeded the oldest entries are evicted FIFO; the only consequence is + * that an evicted (wallet, user) pair may re-emit one identify later in the day. + */ +const MAX_IDENTIFIED_ENTRIES = 30; + export class FormoAnalyticsSession implements IFormoAnalyticsSession { /** * Generate a unique key for wallet identification tracking @@ -171,8 +184,8 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { if (!alreadyExists) { identifiedWallets.push(identifiedKey); - if (identifiedWallets.length > MAX_SESSION_ENTRIES) { - identifiedWallets.splice(0, identifiedWallets.length - MAX_SESSION_ENTRIES); + if (identifiedWallets.length > MAX_IDENTIFIED_ENTRIES) { + identifiedWallets.splice(0, identifiedWallets.length - MAX_IDENTIFIED_ENTRIES); } const newValue = identifiedWallets.join(","); cookie().set(SESSION_WALLET_IDENTIFIED_KEY, newValue, { diff --git a/src/types/base.ts b/src/types/base.ts index 552ef01..71561f7 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -29,6 +29,12 @@ export interface EvmChainState extends ChainState { export type ValidInputTypes = Uint8Array | bigint | string | number | boolean; export interface IFormoAnalytics { + /** + * The wallet address the SDK currently attributes events to, if any. Set by + * `connect()` and by `identify()` (unless `setActive: false`). Read-only from + * a consumer's perspective — use `connect()`/`identify()` to change it. + */ + readonly currentAddress?: Address; page( category?: string, name?: string, @@ -96,15 +102,17 @@ export interface IFormoAnalytics { userId?: string; rdns?: string; /** - * Whether this identify should set the SDK's current wallet address — - * the address later events are attributed to. Defaults to `true`. + * Whether this identify should become the SDK's active identity — the + * `(currentAddress, currentUserId)` pair that later events are attributed + * to. Defaults to `true`. * * Set to `false` when identifying a linked-but-not-active wallet (e.g. - * one of several wallets behind a single Privy user) so it is recorded - * for identity clustering without hijacking event attribution away from - * the wallet the user is actually transacting with. + * one of several wallets behind a single Privy user): the wallet↔user + * link is still emitted and deduped for identity clustering, but neither + * the active address nor the active user ID is changed, so attribution + * stays on the wallet the user is actually transacting with. */ - setCurrentAddress?: boolean; + setActive?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index f858d32..136dea7 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -150,11 +150,53 @@ describe("identifyPrivyUser (integration with real identify)", () => { }; await identifyPrivyUser(formo, user, { activeAddress: EXTERNAL }); - // The embedded wallet was identified with setCurrentAddress:false, so the + // The embedded wallet was identified with setActive:false, so the // active external wallet still owns attribution. expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); }); + it("defaults attribution to the SDK's connected wallet when activeAddress is omitted", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + // A prior connect sets currentAddress to the external wallet. + await formo.identify({ address: EXTERNAL, rdns: "io.metamask" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + + const user: PrivyUser = { + id: DID, + linkedAccounts: [ + { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, + { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, + ], + }; + // No activeAddress passed — the helper should fall back to currentAddress. + await identifyPrivyUser(formo, user); + + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("setActive:false does not repoint the active user id (not just the address)", async () => { + const formo = await makeAnalytics(); + captureIdentifies(formo); + + // Establish an active identity: address EXTERNAL, user "user-A". + await formo.identify({ address: EXTERNAL, userId: "user-A" }); + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + expect(formo.currentUserId).to.equal("user-A"); + + // A non-active identify for a different wallet AND user must leave the + // active (address, userId) pair untouched — no mismatched pairing. + await formo.identify({ + address: EMBEDDED, + userId: "user-B", + setActive: false, + }); + + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + expect(formo.currentUserId).to.equal("user-A"); + }); + 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); diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index 49d2246..7ee027b 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -11,7 +11,7 @@ interface RecordedIdentify { params: { address: string; userId?: string; - setCurrentAddress?: boolean; + setActive?: boolean; rdns?: string; providerName?: string; }; @@ -432,7 +432,7 @@ describe("Privy Utilities", () => { // ...and is the only one that sets the current address. for (const call of calls) { const isActive = call.params.address === EXTERNAL; - expect(call.params.setCurrentAddress).to.equal(isActive); + expect(call.params.setActive).to.equal(isActive); } }); @@ -450,7 +450,7 @@ describe("Privy Utilities", () => { activeAddress: EMBEDDED.toUpperCase(), }); - const active = calls.find((c) => c.params.setCurrentAddress === true); + const active = calls.find((c) => c.params.setActive === true); expect(active?.params.address).to.equal(EMBEDDED); expect(calls[calls.length - 1].params.address).to.equal(EMBEDDED); }); @@ -469,10 +469,10 @@ describe("Privy Utilities", () => { // Embedded wallet is identified first... expect(calls[0].params.address).to.equal(EMBEDDED); - expect(calls[0].params.setCurrentAddress).to.equal(false); + expect(calls[0].params.setActive).to.equal(false); // ...and the external wallet is identified last and owns attribution. expect(calls[1].params.address).to.equal(EXTERNAL); - expect(calls[1].params.setCurrentAddress).to.equal(true); + expect(calls[1].params.setActive).to.equal(true); }); it("merges options.properties into every identify call", async () => { From b937acfa9e1fb9dc9d01f97844e62db8ea068690 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:49:01 +0000 Subject: [PATCH 03/14] refactor(privy): drop core identify() API changes; solve attribution by ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: revert the changes to the core identify() surface that the Privy helper doesn't strictly need, keeping the diff contained to the Privy module. - Removed the `setActive` parameter from identify() and the readonly `currentAddress` field from the IFormoAnalytics interface. The public identify() contract is now unchanged from main. - identifyPrivyUser now handles event attribution purely by ordering — it identifies the active/connected wallet last (embedded-first, then the active wallet, as a best-effort fallback), so identify()'s existing "last write wins" behavior lands attribution on the right wallet. This was the task's sanctioned approach and sidesteps the setActive contract concern entirely (there is no partial-attribution flag to get wrong). - Dropped the activeAddress -> currentAddress fallback (it required exposing currentAddress on the interface); callers pass activeAddress when they want precise attribution. - Reverted MAX_IDENTIFIED_ENTRIES back to the shared MAX_SESSION_ENTRIES (20); the dedup-cap bump was a general session-storage change, not required for the feature. Kept: the userId-in-session-dedup-key fix (required so attaching a DID to an already-identified wallet re-emits), the identifyPrivyUser/useIdentifyPrivyUser helpers, per-wallet metadata forwarding, and docs. Updated tests to assert ordering instead of the removed flag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 33 +++++----- src/FormoAnalytics.ts | 37 ++++------- src/privy/utils.ts | 66 +++++++------------ src/session/index.ts | 17 +---- src/types/base.ts | 18 ----- .../identifyPrivyUser.integration.spec.ts | 47 +------------ test/privy/privy.spec.ts | 22 +++---- 7 files changed, 69 insertions(+), 171 deletions(-) diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index 19e058e..4faeb98 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -92,7 +92,7 @@ For each linked wallet, `identifyPrivyUser` calls: ```ts formo.identify( - { address, userId: user.id, setActive }, + { address, userId: user.id }, { ...profileProperties, // email, socials, privyDid, privyCreatedAt, … wallet_client, // e.g. "metamask", "privy", "rainbow" @@ -102,6 +102,9 @@ formo.identify( ); ``` +The wallets are identified in a deliberate order — the active wallet last — so +event attribution lands on it rather than an arbitrary linked wallet (see 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, @@ -128,23 +131,21 @@ which one they're using right now. Since `identify()` also updates the SDK's over every linked wallet would leave attribution on whichever wallet happened to be identified last. -`identifyPrivyUser` handles this for you: +`identifyPrivyUser` handles this by **ordering** the loop — it identifies the +active wallet last, so it's the one that ends up as the current address. No +special `identify()` flag is involved; the core API is unchanged. - **When you pass `activeAddress`** (from `useWallets()` or your wagmi account), - that wallet is identified **last** and is the **only** one promoted to the - active identity. Every other wallet is identified with `setActive: false`, so - it's recorded for clustering without hijacking attribution — and `setActive: - false` guards both the active address *and* the active user ID, so a non-active - wallet can never leave the SDK pairing your connected wallet with the wrong - user. -- **When you omit it**, the SDK first falls back to its own `currentAddress` (set - by a prior wagmi/EIP-1193 connect) — so if you already track connects, you - usually don't need to pass anything. Failing that, it uses a best-effort order: - embedded (Privy) wallets first, external wallets last, attributing to the last - external wallet. - -Pass `activeAddress` when you have it and the SDK isn't already tracking the -connected wallet; otherwise the default is usually right. + that wallet is moved to the end of the loop and wins attribution. The other + linked wallets are still identified (for clustering) but earlier, so they don't + end up as the current address. +- **When you omit it**, the helper uses a best-effort order: embedded (Privy) + wallets first, external wallets last, attributing to the last external wallet. + +Pass `activeAddress` when you can derive it for precise attribution; otherwise +the best-effort order is usually right. If you already track the connected wallet +via a separate `connect()`/wagmi flow, that remains the source of truth for +attribution once the user transacts. ## When identity re-emits diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 1bae495..3585b27 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -703,7 +703,6 @@ export class FormoAnalytics implements IFormoAnalytics { providerName?: string; userId?: string; rdns?: string; - setActive?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, @@ -787,7 +786,7 @@ export class FormoAnalytics implements IFormoAnalytics { return; } - const { address, providerName, userId, rdns, setActive } = params; + const { address, providerName, userId, rdns } = params; // Runtime validation: address is required if (!address) { @@ -798,31 +797,21 @@ export class FormoAnalytics implements IFormoAnalytics { // Explicit identify logger.info("Identify", address, userId, providerName, rdns); const validAddress = validateAddress(address); - if (!validAddress) { + if (validAddress) { + this.currentAddress = validAddress; + this.persistActiveWallet(); + } else { logger.warn?.("Invalid address provided to identify:", address); return; } - // Promote this wallet to the SDK's active identity — the (address, - // userId) pair later events are attributed to — unless the caller opts - // out with `setActive: false`. Both the active address AND the active - // user ID are gated together: a non-active identify records the - // wallet↔user link (event emission + session dedup below) for clustering - // without repointing attribution. This matters when identifying several - // linked wallets that share one user (e.g. a Privy DID): only the active - // wallet should own attribution, and it must not be left 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 } : {}), - }); - } + 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. The userId is diff --git a/src/privy/utils.ts b/src/privy/utils.ts index bba13a7..bbf736c 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -252,18 +252,14 @@ export interface IdentifyPrivyUserOptions { * * A Privy user's `linkedAccounts` lists every wallet they have ever linked, * not which one is currently connected, so the SDK cannot tell them apart on - * its own. When resolved, that wallet is identified last and becomes the - * address later events are attributed to, while the other linked wallets are - * recorded for identity clustering without taking over attribution. + * its own. When provided (and it matches a linked wallet), that wallet is + * identified last so it wins event attribution, while the other linked + * wallets are still recorded for identity clustering. * - * Resolution order: - * 1. this `activeAddress`, if it matches a linked wallet; - * 2. else the SDK's existing `currentAddress` (e.g. from a prior wagmi - * connect), if it matches a linked wallet; - * 3. else a best-effort guess: embedded (Privy) wallets first, attributing to - * the last external wallet. - * - * So if you already track wallet connects, you can usually omit this. + * When omitted, the helper falls back to a best-effort order: embedded + * (Privy) wallets first, attributing to the last external wallet. Pass this + * whenever you can derive it (e.g. `useWallets()[0]?.address`) for precise + * attribution. */ activeAddress?: string; @@ -290,10 +286,11 @@ export interface IdentifyPrivyUserOptions { * `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/connected wallet (see - * {@link IdentifyPrivyUserOptions.activeAddress}) is promoted to the SDK's - * active identity; the rest are identified with `setActive: false` so they do - * not hijack which wallet subsequent events are attributed to. + * Attribution: the active/connected wallet (see + * {@link IdentifyPrivyUserOptions.activeAddress}) is identified last so it ends + * up as the SDK's current address; the other linked wallets are identified + * first, purely for clustering. This is done by ordering alone — no special + * `identify()` flag — so the core API is unchanged. * * All linked wallet addresses used here come from `user.linkedAccounts`, which * is fully available on the frontend from Privy's `usePrivy()` hook. @@ -346,20 +343,20 @@ export async function identifyPrivyUser( ...options.properties, }; - // Resolve the active/connected wallet (case-insensitive). Fall back to the - // SDK's existing currentAddress (e.g. from a prior wagmi connect) so callers - // that already track connects don't have to pass activeAddress. Only honored - // if it actually matches one of the linked wallets. - const activeAddress = ( - options.activeAddress ?? analytics.currentAddress - )?.toLowerCase(); + // Resolve the active/connected wallet (case-insensitive), if one was given + // and it matches a linked wallet. + const activeAddress = options.activeAddress?.toLowerCase(); const hasActive = !!activeAddress && wallets.some((w) => w.address.toLowerCase() === activeAddress); - // Order embedded (Privy) wallets first and external wallets last. When we - // know the active wallet, move it to the very end. identify() is called in - // order, so the last wallet flagged with setActive wins attribution. + // Order embedded (Privy) wallets first and external wallets last; when the + // active wallet is known, move it to the very end. identify() is called in + // this order and each call updates the SDK's current address, so the last + // wallet identified — the active one, or the last external wallet as a + // best-effort fallback — wins event attribution. No change to the core + // identify() API is needed: ordering alone keeps attribution off an + // arbitrary linked wallet. const embedded = wallets.filter((w) => w.isEmbedded); const external = wallets.filter((w) => !w.isEmbedded); let ordered: PrivyWalletInfo[] = [...embedded, ...external]; @@ -373,18 +370,7 @@ export async function identifyPrivyUser( ]; } - const lastIndex = ordered.length - 1; - - for (let i = 0; i < ordered.length; i++) { - const wallet = ordered[i]; - - // The wallet that should own event attribution: the resolved active wallet - // if known, otherwise the last wallet in embedded-first order (a best-effort - // guess that prefers an external wallet over an embedded one). - const isAttributionWallet = hasActive - ? wallet.address.toLowerCase() === activeAddress - : i === lastIndex; - + for (const wallet of ordered) { const walletProperties: IFormoEventProperties = { ...baseProperties, is_embedded: wallet.isEmbedded, @@ -393,11 +379,7 @@ export async function identifyPrivyUser( if (wallet.chainType) walletProperties.chain_type = wallet.chainType; await analytics.identify( - { - address: wallet.address, - userId: user.id, - setActive: isAttributionWallet, - }, + { address: wallet.address, userId: user.id }, walletProperties ); } diff --git a/src/session/index.ts b/src/session/index.ts index 2b26d4d..fd142dc 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -64,19 +64,6 @@ export interface IFormoAnalyticsSession { */ const MAX_SESSION_ENTRIES = 20; -/** - * Cap for identified wallet-address entries. Higher than {@link - * MAX_SESSION_ENTRIES} because a single user can identify many wallets in one - * session — a Privy user with N linked wallets consumes up to 2N entries when - * each is seen anonymously and then again with a DID (plus per-provider RDNS - * variants). The cap is deliberately bounded, not unlimited: entries are stored - * comma-joined in a cookie, and a worst-case key (`address:rdns:did`, ~110 - * bytes) times this cap stays under the ~4KB per-cookie browser limit. When the - * cap is exceeded the oldest entries are evicted FIFO; the only consequence is - * that an evicted (wallet, user) pair may re-emit one identify later in the day. - */ -const MAX_IDENTIFIED_ENTRIES = 30; - export class FormoAnalyticsSession implements IFormoAnalyticsSession { /** * Generate a unique key for wallet identification tracking @@ -184,8 +171,8 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { if (!alreadyExists) { identifiedWallets.push(identifiedKey); - if (identifiedWallets.length > MAX_IDENTIFIED_ENTRIES) { - identifiedWallets.splice(0, identifiedWallets.length - MAX_IDENTIFIED_ENTRIES); + if (identifiedWallets.length > MAX_SESSION_ENTRIES) { + identifiedWallets.splice(0, identifiedWallets.length - MAX_SESSION_ENTRIES); } const newValue = identifiedWallets.join(","); cookie().set(SESSION_WALLET_IDENTIFIED_KEY, newValue, { diff --git a/src/types/base.ts b/src/types/base.ts index 71561f7..bd152e9 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -29,12 +29,6 @@ export interface EvmChainState extends ChainState { export type ValidInputTypes = Uint8Array | bigint | string | number | boolean; export interface IFormoAnalytics { - /** - * The wallet address the SDK currently attributes events to, if any. Set by - * `connect()` and by `identify()` (unless `setActive: false`). Read-only from - * a consumer's perspective — use `connect()`/`identify()` to change it. - */ - readonly currentAddress?: Address; page( category?: string, name?: string, @@ -101,18 +95,6 @@ export interface IFormoAnalytics { providerName?: string; userId?: string; rdns?: string; - /** - * Whether this identify should become the SDK's active identity — the - * `(currentAddress, currentUserId)` pair that later events are attributed - * to. Defaults to `true`. - * - * Set to `false` when identifying a linked-but-not-active wallet (e.g. - * one of several wallets behind a single Privy user): the wallet↔user - * link is still emitted and deduped for identity clustering, but neither - * the active address nor the active user ID is changed, so attribution - * stays on the wallet the user is actually transacting with. - */ - setActive?: boolean; }, properties?: IFormoEventProperties, context?: IFormoEventContext, diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index 136dea7..eb4af9e 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -150,53 +150,12 @@ describe("identifyPrivyUser (integration with real identify)", () => { }; await identifyPrivyUser(formo, user, { activeAddress: EXTERNAL }); - // The embedded wallet was identified with setActive:false, so the - // active external wallet still owns attribution. + // 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("defaults attribution to the SDK's connected wallet when activeAddress is omitted", async () => { - const formo = await makeAnalytics(); - captureIdentifies(formo); - - // A prior connect sets currentAddress to the external wallet. - await formo.identify({ address: EXTERNAL, rdns: "io.metamask" }); - expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); - - const user: PrivyUser = { - id: DID, - linkedAccounts: [ - { type: "wallet", address: EMBEDDED, walletClientType: "privy" }, - { type: "wallet", address: EXTERNAL, walletClientType: "metamask" }, - ], - }; - // No activeAddress passed — the helper should fall back to currentAddress. - await identifyPrivyUser(formo, user); - - expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); - }); - - it("setActive:false does not repoint the active user id (not just the address)", async () => { - const formo = await makeAnalytics(); - captureIdentifies(formo); - - // Establish an active identity: address EXTERNAL, user "user-A". - await formo.identify({ address: EXTERNAL, userId: "user-A" }); - expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); - expect(formo.currentUserId).to.equal("user-A"); - - // A non-active identify for a different wallet AND user must leave the - // active (address, userId) pair untouched — no mismatched pairing. - await formo.identify({ - address: EMBEDDED, - userId: "user-B", - setActive: false, - }); - - expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); - expect(formo.currentUserId).to.equal("user-A"); - }); - 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); diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index 7ee027b..13dfd1a 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -11,7 +11,6 @@ interface RecordedIdentify { params: { address: string; userId?: string; - setActive?: boolean; rdns?: string; providerName?: string; }; @@ -427,13 +426,15 @@ describe("Privy Utilities", () => { await identifyPrivyUser(analytics, user, { activeAddress: EXTERNAL }); - // Active wallet is identified last... + // Active wallet is identified last, so it wins attribution (identify() + // sets the current address on each call, last write wins). expect(calls[calls.length - 1].params.address).to.equal(EXTERNAL); - // ...and is the only one that sets the current address. - for (const call of calls) { - const isActive = call.params.address === EXTERNAL; - expect(call.params.setActive).to.equal(isActive); - } + // ...and every wallet is still identified for clustering. + expect(calls.map((c) => c.params.address)).to.have.members([ + EMBEDDED, + EXTERNAL, + EXTERNAL_2, + ]); }); it("matches the active wallet case-insensitively", async () => { @@ -450,8 +451,7 @@ describe("Privy Utilities", () => { activeAddress: EMBEDDED.toUpperCase(), }); - const active = calls.find((c) => c.params.setActive === true); - expect(active?.params.address).to.equal(EMBEDDED); + // Case-insensitive match still puts the active wallet last. expect(calls[calls.length - 1].params.address).to.equal(EMBEDDED); }); @@ -469,10 +469,8 @@ describe("Privy Utilities", () => { // Embedded wallet is identified first... expect(calls[0].params.address).to.equal(EMBEDDED); - expect(calls[0].params.setActive).to.equal(false); - // ...and the external wallet is identified last and owns attribution. + // ...and the external wallet is identified last, so it owns attribution. expect(calls[1].params.address).to.equal(EXTERNAL); - expect(calls[1].params.setActive).to.equal(true); }); it("merges options.properties into every identify call", async () => { From 7bdc05b645e4dc52a18e5552fe0d87233a9e7cf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:00:24 +0000 Subject: [PATCH 04/14] feat(privy): add identify(user, { privy: true }); drop the React hook Replace the useIdentifyPrivyUser hook with a flag on identify() so a Privy app needs only a single function: identify(user, { privy: true }). - identify() gains an overload: identify(user, { privy: true, activeAddress?, properties? }). When the { privy: true } flag is present, identify() treats the first argument as a Privy user and delegates to identifyPrivyUser, which expands user.linkedAccounts into one identify per wallet under the DID. The Privy-specific logic stays in the privy module; core identify() only dispatches. A normal identify({ address }, properties) is unaffected (the flag lives in the options position, and an address-shaped first arg never matches). - Removed the useIdentifyPrivyUser React hook and src/privy/react.ts. Apps call formo.identify(user, { privy: true }) from their own effect (as the with-privy example already does) instead of mounting a hook. - identifyPrivyUser and parsePrivyProperties stay exported; the flag is sugar over identifyPrivyUser. - Docs rewritten around the single-call form; added integration tests that the flag dispatches through the real identify() and that a normal identify with a `privy` property is not misinterpreted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 81 ++++++++++------ src/FormoAnalytics.ts | 57 ++++++++++- src/index.ts | 5 - src/privy/index.ts | 11 +-- src/privy/react.ts | 97 ------------------- src/types/base.ts | 15 +++ .../identifyPrivyUser.integration.spec.ts | 45 +++++++++ 7 files changed, 168 insertions(+), 143 deletions(-) delete mode 100644 src/privy/react.ts diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index 4faeb98..e4b44a6 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -22,45 +22,60 @@ today, connect three more next week — they all roll up under the same identity Formo clusters them into one user ``` -This is the **one-liner** replacement for hand-rolling an `identify()` loop. +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) -Drop `useIdentifyPrivyUser` into any component rendered under -`FormoAnalyticsProvider` and hand it the Privy `user`. It keeps Formo's identity -in sync automatically — on login, on `linkWallet`, and on `unlinkWallet`. +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, useWallets } from "@privy-io/react-auth"; -import { useIdentifyPrivyUser } from "@formo/analytics"; +import { useFormo } from "@formo/analytics"; function AnalyticsIdentity() { - const { user, authenticated, ready } = usePrivy(); + const formo = useFormo(); + const { user, authenticated } = usePrivy(); const { wallets } = useWallets(); - useIdentifyPrivyUser(user, { - enabled: ready && authenticated, - // The currently-connected wallet — identified last so event attribution - // stays on the wallet the user is actually transacting with. - activeAddress: wallets[0]?.address, - }); + useEffect(() => { + if (formo && authenticated && user) { + formo.identify(user, { + privy: true, + // The currently-connected wallet — identified last so event + // attribution stays on the wallet the user is transacting with. + activeAddress: wallets[0]?.address, + }); + } + }, [formo, authenticated, user, wallets]); return null; } ``` -That's it. Every wallet linked to the Privy user is identified under the user's -DID, with per-wallet metadata forwarded, and event attribution pinned to the -active wallet. +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. -> The hook is keyed on a stable signature of the DID + the set of linked wallet -> addresses (not on the `user` object reference, which Privy re-creates every -> render), so it only re-runs when something meaningful changes. +## 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`, calling the normal +`identify({ address, userId })` once per linked wallet. The Privy-specific logic +lives in the SDK's Privy module; the core `identify()` just dispatches to it. ## Framework-agnostic usage -Not using React (or want to call it imperatively)? Use `identifyPrivyUser` -directly — it works with the `core` entry too. +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"; @@ -73,6 +88,10 @@ if (user) { } ``` +`formo.identify(user, { privy: true, activeAddress, properties })` and +`identifyPrivyUser(formo, user, { activeAddress, properties })` are equivalent — +the former is sugar over the latter. + ### Signature ```ts @@ -158,9 +177,9 @@ Formo deduplicates identify events per session. The dedup key now includes the once the Privy DID is attached after login. - Switching Privy users on the same wallet re-emits under the new DID. -Combined with the React hook, login and `linkWallet` produce exactly the -identify events you'd expect and nothing more. `unlinkWallet` re-runs the hook -for the remaining wallets but emits no event of its own — see +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` @@ -199,11 +218,11 @@ related product concerns are out of scope for it today: 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.** The helper emits positive wallet↔user link events - only. When a wallet is unlinked in Privy the React hook 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. +- **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. @@ -242,9 +261,9 @@ 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?** -The React hook re-runs whenever the linked-wallet set changes, so newly linked -wallets are identified automatically. With the imperative helper, just call -`identifyPrivyUser` again after a `linkWallet` succeeds. +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 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 3585b27..75fff83 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,13 +692,21 @@ export class FormoAnalytics implements IFormoAnalytics { * // Basic identify * formo.identify({ address: '0x...', userId: 'user123' }); * - * // With a Privy user, prefer the one-liner helper which forwards - * // per-wallet metadata and keeps event attribution on the active wallet: - * import { identifyPrivyUser } from '@formo/analytics'; + * // Privy: pass the usePrivy() user with `{ privy: true }` to identify every + * // linked wallet under the user's DID in one call. * const { user } = usePrivy(); - * if (user) await identifyPrivyUser(formo, user); + * const { wallets } = useWallets(); + * if (user) formo.identify(user, { privy: true, activeAddress: wallets[0]?.address }); * ``` */ + async identify( + user: PrivyUser, + options: { + privy: true; + activeAddress?: string; + properties?: IFormoEventProperties; + } + ): Promise; async identify( params?: { address: Address; @@ -707,8 +717,47 @@ 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. + if ( + propertiesOrOptions && + (propertiesOrOptions as { privy?: unknown }).privy === true + ) { + const opts = propertiesOrOptions as { + activeAddress?: string; + properties?: IFormoEventProperties; + }; + await identifyPrivyUser(this, paramsOrUser as PrivyUser, { + activeAddress: opts.activeAddress, + properties: opts.properties, + }); + return; + } + + const params = paramsOrUser as + | { address: Address; providerName?: string; userId?: string; rdns?: string } + | 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 / diff --git a/src/index.ts b/src/index.ts index bcb5cf7..fd1f0b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,4 @@ import { formofy } from "./initialization"; export * from "./core"; export * from "./FormoAnalyticsProvider"; -// React-only Privy binding. Lives in the root entry (not `core`) because it -// imports React. -export { useIdentifyPrivyUser } from "./privy/react"; -export type { UseIdentifyPrivyUserOptions } from "./privy/react"; - if (typeof window !== "undefined") window.formofy = formofy; diff --git a/src/privy/index.ts b/src/privy/index.ts index 3bb6de6..904140c 100644 --- a/src/privy/index.ts +++ b/src/privy/index.ts @@ -1,13 +1,12 @@ /** * Privy integration module * - * Provides utilities for enriching wallet profiles with Privy user data. - * This module exports the property extraction utility, the one-liner - * `identifyPrivyUser` helper, 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 })`. * - * Note: the React binding (`useIdentifyPrivyUser`) is intentionally NOT - * re-exported here so this module stays free of a React dependency and can be - * used from the React-free `core` entry. Import the hook from the package root. + * This module is React-free so it can be used from the `core` entry. */ export { parsePrivyProperties, identifyPrivyUser } from "./utils"; diff --git a/src/privy/react.ts b/src/privy/react.ts deleted file mode 100644 index 1e6ecf6..0000000 --- a/src/privy/react.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Optional React binding for Privy identity. - * - * This module imports React and therefore lives outside the React-free `core` - * entry point. Import it from the package root (`@formo/analytics`). - */ - -import { useEffect } from "react"; -import { useFormo } from "../FormoAnalyticsProvider"; -import { logger } from "../logger"; -import { - identifyPrivyUser, - isPrivyWalletAccount, - IdentifyPrivyUserOptions, -} from "./utils"; -import { PrivyUser } from "./types"; - -export interface UseIdentifyPrivyUserOptions extends IdentifyPrivyUserOptions { - /** - * Gate the effect. Pass Privy's `authenticated` (or `ready && authenticated`) - * so nothing is identified while auth is still resolving or the user is - * logged out. Defaults to `true`. - */ - enabled?: boolean; -} - -/** - * Keep Formo's identity in sync with the Privy `usePrivy()` user object. - * - * Drop this hook into any component under `FormoAnalyticsProvider` and pass it - * the Privy `user`. Whenever the user's Privy DID or the set of linked wallet - * addresses changes — covering login, `linkWallet` success, and `unlinkWallet` - * — it re-runs {@link identifyPrivyUser}, so every wallet stays tagged with the - * user's DID for server-side clustering. - * - * The effect is keyed on a stable signature of the DID + sorted linked-wallet - * addresses + active address, not on the `user` object reference (which Privy - * re-creates on every render), so it fires only on meaningful changes. - * `options.properties` is intentionally NOT part of the key: identify events - * are deduped per `(wallet, user)` per session, so property-only changes would - * not re-emit anyway — treat properties as identity metadata set at first - * identify (see {@link IdentifyPrivyUserOptions.properties}). - * - * Scope: this emits positive identify (wallet↔user link) events only. Unlinking - * a wallet re-runs the effect for the smaller set, but there is no SDK-level - * "unlink" event, so links are additive from the backend's perspective. - * - * @example - * ```tsx - * import { usePrivy, useWallets } from '@privy-io/react-auth'; - * import { useIdentifyPrivyUser } from '@formo/analytics'; - * - * function AnalyticsIdentity() { - * const { user, authenticated, ready } = usePrivy(); - * const { wallets } = useWallets(); - * useIdentifyPrivyUser(user, { - * enabled: ready && authenticated, - * activeAddress: wallets[0]?.address, - * }); - * return null; - * } - * ``` - */ -export function useIdentifyPrivyUser( - user: PrivyUser | null | undefined, - options: UseIdentifyPrivyUserOptions = {} -): void { - const analytics = useFormo(); - const { enabled = true, activeAddress, properties } = options; - - // A stable, order-independent signature of the linked wallet set. Used so the - // effect re-runs when a wallet is linked or unlinked, but not on every render. - const walletSignature = user - ? (user.linkedAccounts || []) - .filter(isPrivyWalletAccount) - .map((a) => (a.address as string).toLowerCase()) - .sort() - .join(",") - : ""; - - // Empty signature => nothing to do (logged out or disabled). - const signature = - user && enabled - ? [user.id, activeAddress?.toLowerCase() ?? "", walletSignature].join("|") - : ""; - - useEffect(() => { - if (!signature || !user) return; - identifyPrivyUser(analytics, user, { activeAddress, properties }).catch( - (err) => logger.error("useIdentifyPrivyUser: identify failed", err) - ); - // Keyed on the derived `signature` string rather than the `user`/`options` - // object references, which change identity on every render. `user`, - // `activeAddress`, and `properties` are read fresh inside the effect and - // are consistent with the signature that triggered it. - }, [analytics, signature]); -} diff --git a/src/types/base.ts b/src/types/base.ts index bd152e9..c4f3576 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,20 @@ 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`, forwards each + * wallet's metadata, and identifies the active wallet last for attribution. + */ + identify( + user: PrivyUser, + options: { + privy: true; + activeAddress?: string; + properties?: IFormoEventProperties; + } + ): Promise; identify( params: { address: Address; diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index eb4af9e..f29a7bf 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -132,6 +132,51 @@ describe("identifyPrivyUser (integration with real identify)", () => { 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"); + } + // Active wallet identified last → owns attribution. + expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); + }); + + it("does not treat a normal identify with 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, + // even though we pass properties; the flag lives in the options position. + await formo.identify({ address: EXTERNAL, userId: "plain" }, { 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.plan).to.equal("pro"); + }); + it("does not let a non-active linked wallet hijack currentAddress after a real connect", async () => { const formo = await makeAnalytics(); captureIdentifies(formo); From b3f8ad1b096ddac6cd45b3828d3a1e8dd6b6c935 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 10:32:19 +0000 Subject: [PATCH 05/14] feat(privy): default attribution to user.wallet; make activeAddress optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You can now call formo.identify(user, { privy: true }) with no address at all. - When activeAddress is omitted, identifyPrivyUser now falls back to Privy's own surfaced primary wallet (user.wallet) before the embedded-first heuristic, so the caller doesn't need to derive or pass the active wallet. - activeAddress stays as an optional override for callers who want to pin attribution to a specific wallet (e.g. the live connected wallet from useWallets()[0]?.address / wagmi account), which reflects the active wallet more precisely than Privy's persisted user.wallet. - Docs + example simplified to the no-argument form; added tests that user.wallet is used as the default active wallet and that an explicit activeAddress overrides it. Note: Privy does not persist a live "active wallet" on the user object — that is a runtime useWallets() concept the SDK can't see from outside React. user.wallet is Privy's surfaced primary and a reasonable default; pass activeAddress when you need the precise live wallet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 44 ++++++++++++++++++--------------------- src/FormoAnalytics.ts | 6 +++--- src/privy/utils.ts | 31 ++++++++++++++------------- test/privy/privy.spec.ts | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 42 deletions(-) diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index e4b44a6..6c79cbc 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -33,24 +33,18 @@ an effect that runs when the user changes, so login, `linkWallet`, and ```tsx import { useEffect } from "react"; -import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { usePrivy } from "@privy-io/react-auth"; import { useFormo } from "@formo/analytics"; function AnalyticsIdentity() { const formo = useFormo(); const { user, authenticated } = usePrivy(); - const { wallets } = useWallets(); useEffect(() => { if (formo && authenticated && user) { - formo.identify(user, { - privy: true, - // The currently-connected wallet — identified last so event - // attribution stays on the wallet the user is transacting with. - activeAddress: wallets[0]?.address, - }); + formo.identify(user, { privy: true }); } - }, [formo, authenticated, user, wallets]); + }, [formo, authenticated, user]); return null; } @@ -150,21 +144,23 @@ which one they're using right now. Since `identify()` also updates the SDK's over every linked wallet would leave attribution on whichever wallet happened to be identified last. -`identifyPrivyUser` handles this by **ordering** the loop — it identifies the -active wallet last, so it's the one that ends up as the current address. No -special `identify()` flag is involved; the core API is unchanged. - -- **When you pass `activeAddress`** (from `useWallets()` or your wagmi account), - that wallet is moved to the end of the loop and wins attribution. The other - linked wallets are still identified (for clustering) but earlier, so they don't - end up as the current address. -- **When you omit it**, the helper uses a best-effort order: embedded (Privy) - wallets first, external wallets last, attributing to the last external wallet. - -Pass `activeAddress` when you can derive it for precise attribution; otherwise -the best-effort order is usually right. If you already track the connected wallet -via a separate `connect()`/wagmi flow, that remains the source of truth for -attribution once the user transacts. +The SDK handles this by **ordering** the loop — it identifies the chosen wallet +last, so it's the one that ends up as the current address. The 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); +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 order: embedded (Privy) wallets first, attributing to the + last external wallet. + +In practice you can just call `formo.identify(user, { privy: true })` and let it +default to Privy's primary wallet. Pass `activeAddress` only when you want to pin +attribution to a specific wallet. If you already track the connected wallet via a +separate `connect()`/wagmi flow, that remains the source of truth for attribution +once the user transacts. ## When identity re-emits diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 75fff83..1b9b0f0 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -693,10 +693,10 @@ export class FormoAnalytics implements IFormoAnalytics { * formo.identify({ address: '0x...', userId: 'user123' }); * * // Privy: pass the usePrivy() user with `{ privy: true }` to identify every - * // linked wallet under the user's DID in one call. + * // linked wallet under the user's DID in one call. Attribution defaults to + * // Privy's primary wallet (user.wallet); pass `activeAddress` to override. * const { user } = usePrivy(); - * const { wallets } = useWallets(); - * if (user) formo.identify(user, { privy: true, activeAddress: wallets[0]?.address }); + * if (user) formo.identify(user, { privy: true }); * ``` */ async identify( diff --git a/src/privy/utils.ts b/src/privy/utils.ts index bbf736c..acdd31c 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -246,20 +246,17 @@ export function parsePrivyProperties(user: PrivyUser): { */ export interface IdentifyPrivyUserOptions { /** - * Address of the wallet the user is actively transacting with — typically - * the connected wallet from Privy's `useWallets()` (`wallets[0]?.address`) - * or your wagmi account. + * Optional override for the wallet that should own event attribution — the + * one identified last, so later events are attributed to it. * - * A Privy user's `linkedAccounts` lists every wallet they have ever linked, - * not which one is currently connected, so the SDK cannot tell them apart on - * its own. When provided (and it matches a linked wallet), that wallet is - * identified last so it wins event attribution, while the other linked - * wallets are still recorded for identity clustering. + * You usually don't need this. When omitted, the helper defaults to Privy's + * own surfaced wallet (`user.wallet`), then to a best-effort order (embedded + * wallets first, attributing to 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` or your wagmi account, + * which reflects the live active wallet more precisely than `user.wallet`. * - * When omitted, the helper falls back to a best-effort order: embedded - * (Privy) wallets first, attributing to the last external wallet. Pass this - * whenever you can derive it (e.g. `useWallets()[0]?.address`) for precise - * attribution. + * Ignored if it doesn't match one of the user's linked wallets. */ activeAddress?: string; @@ -343,9 +340,13 @@ export async function identifyPrivyUser( ...options.properties, }; - // Resolve the active/connected wallet (case-insensitive), if one was given - // and it matches a linked wallet. - const activeAddress = options.activeAddress?.toLowerCase(); + // Resolve the wallet that should own event attribution (case-insensitive): + // an explicit activeAddress wins, otherwise fall back to Privy's own surfaced + // wallet (`user.wallet`) — its designated primary — so callers don't have to + // pass anything. Only honored if it matches one of the linked wallets. + const activeAddress = ( + options.activeAddress ?? user.wallet?.address + )?.toLowerCase(); const hasActive = !!activeAddress && wallets.some((w) => w.address.toLowerCase() === activeAddress); diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index 13dfd1a..23ded97 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -473,6 +473,42 @@ describe("Privy Utilities", () => { expect(calls[1].params.address).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 treated as active → identified last, + // overriding the embedded-first heuristic (which would end on EXTERNAL). + expect(calls[calls.length - 1].params.address).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 }); + + // Explicit override wins over user.wallet. + expect(calls[calls.length - 1].params.address).to.equal(EXTERNAL); + }); + it("merges options.properties into every identify call", async () => { const user: PrivyUser = { id: "did:privy:abc123", From ed0006a3fe462d0d282969e0cec20e540281220a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 03:34:57 +0000 Subject: [PATCH 06/14] =?UTF-8?q?fix(privy):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=204=20correctness=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - identify() Privy dispatch: require the first argument to be Privy-user-shaped (string `id`, no `address`), not just `{ privy: true }`. A normal identify that happens to carry a property named `privy` no longer misdispatches and drops the event. (Codex P2) - identifyPrivyUser: prefer the SDK's already-active currentAddress (a connected wallet) over Privy's user.wallet when resolving attribution, so the multi-wallet identify loop doesn't overwrite the connected wallet. The flag form passes this.currentAddress through as the default. (Codex P2) - identifyPrivyUser: compare wallet addresses with chain-appropriate casing — case-insensitive for EVM (0x hex), exact for Solana/Base58 — so a mis-cased Solana address can't match the wrong wallet. (Codex P2) - session dedup: percent-encode key components before the comma-joined cookie, so an external userId containing a comma can't corrupt the key and defeat dedup. Address/RDNS encode to themselves, so existing keys still match. (Codex P3) Added tests for each; 673 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- src/FormoAnalytics.ts | 21 +++++++-- src/privy/utils.ts | 43 ++++++++++++------- src/session/index.ts | 14 ++++-- .../identifyPrivyUser.integration.spec.ts | 39 +++++++++++++++-- test/privy/privy.spec.ts | 28 ++++++++++++ test/session/dedup.spec.ts | 13 ++++++ 6 files changed, 131 insertions(+), 27 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 1b9b0f0..b1fd71a 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -738,16 +738,31 @@ export class FormoAnalytics implements IFormoAnalytics { // 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 + (propertiesOrOptions as { privy?: unknown }).privy === true && + maybeUser && + typeof maybeUser.id === "string" && + maybeUser.address === undefined ) { const opts = propertiesOrOptions as { activeAddress?: string; properties?: IFormoEventProperties; }; - await identifyPrivyUser(this, paramsOrUser as PrivyUser, { - activeAddress: opts.activeAddress, + await identifyPrivyUser(this, maybeUser as PrivyUser, { + // Prefer an explicit override, else the wallet the SDK already treats + // as active (e.g. from a wagmi/EIP-1193 connect) so this multi-wallet + // identify doesn't overwrite attribution with Privy's primary wallet. + activeAddress: opts.activeAddress ?? this.currentAddress, properties: opts.properties, }); return; diff --git a/src/privy/utils.ts b/src/privy/utils.ts index acdd31c..ff7fb1b 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -25,6 +25,21 @@ export function isPrivyWalletAccount(account: PrivyLinkedAccount): boolean { ); } +/** 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. * @@ -340,16 +355,15 @@ export async function identifyPrivyUser( ...options.properties, }; - // Resolve the wallet that should own event attribution (case-insensitive): - // an explicit activeAddress wins, otherwise fall back to Privy's own surfaced - // wallet (`user.wallet`) — its designated primary — so callers don't have to - // pass anything. Only honored if it matches one of the linked wallets. - const activeAddress = ( - options.activeAddress ?? user.wallet?.address - )?.toLowerCase(); - const hasActive = - !!activeAddress && - wallets.some((w) => w.address.toLowerCase() === activeAddress); + // Resolve the wallet that should own event attribution: an explicit + // activeAddress wins, otherwise fall back to Privy's own surfaced wallet + // (`user.wallet`) — its designated primary — so callers don't have to pass + // anything. Only honored if it matches one of the linked wallets (using + // chain-appropriate address comparison, so Solana casing isn't mismatched). + const activeAddress = options.activeAddress ?? user.wallet?.address; + const activeWallet = activeAddress + ? wallets.find((w) => sameAddress(w.address, activeAddress)) + : undefined; // Order embedded (Privy) wallets first and external wallets last; when the // active wallet is known, move it to the very end. identify() is called in @@ -361,13 +375,10 @@ export async function identifyPrivyUser( const embedded = wallets.filter((w) => w.isEmbedded); const external = wallets.filter((w) => !w.isEmbedded); let ordered: PrivyWalletInfo[] = [...embedded, ...external]; - if (hasActive) { - const active = wallets.find( - (w) => w.address.toLowerCase() === activeAddress - )!; + if (activeWallet) { ordered = [ - ...ordered.filter((w) => w.address.toLowerCase() !== activeAddress), - active, + ...ordered.filter((w) => w !== activeWallet), + activeWallet, ]; } diff --git a/src/session/index.ts b/src/session/index.ts index fd142dc..288b381 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -86,10 +86,16 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { rdns: string, userId?: string ): string { - // If rdns is missing, use address-only key as fallback for empty identifies - const parts = [address]; - if (rdns) parts.push(rdns); - if (userId) parts.push(userId); + // 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 (rdns) parts.push(encodeURIComponent(rdns)); + if (userId) parts.push(encodeURIComponent(userId)); return parts.join(":"); } diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index f29a7bf..cae3b21 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -163,20 +163,51 @@ describe("identifyPrivyUser (integration with real identify)", () => { expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); }); - it("does not treat a normal identify with a `privy` property as the Privy form", async () => { + 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, - // even though we pass properties; the flag lives in the options position. - await formo.identify({ address: EXTERNAL, userId: "plain" }, { plan: "pro" }); + // 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("does not let a non-active linked wallet hijack currentAddress after a real connect", async () => { const formo = await makeAnalytics(); captureIdentifies(formo); diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index 23ded97..cdb61fe 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -509,6 +509,34 @@ describe("Privy Utilities", () => { expect(calls[calls.length - 1].params.address).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: [ + // embedded Solana wallet (would be identified first by the heuristic) + { type: "wallet", address: SOL, walletClientType: "privy", chainType: "solana" }, + // external EVM wallet (heuristic attributes here, last) + { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, + ], + }; + + // Wrong-case Solana address must NOT match → attribution falls back to the + // heuristic (external EXTERNAL last), not the mis-cased SOL wallet. + const miss = makeRecorder(); + await identifyPrivyUser(miss.analytics, user, { + activeAddress: SOL.toLowerCase(), + }); + expect(miss.calls[miss.calls.length - 1].params.address).to.equal(EXTERNAL); + + // Exact-case Solana address IS accepted → moved last. + const hit = makeRecorder(); + await identifyPrivyUser(hit.analytics, user, { activeAddress: SOL }); + expect(hit.calls[hit.calls.length - 1].params.address).to.equal(SOL); + }); + it("merges options.properties into every identify call", async () => { const user: PrivyUser = { id: "did:privy:abc123", diff --git a/test/session/dedup.spec.ts b/test/session/dedup.spec.ts index aed014d..d75311b 100644 --- a/test/session/dedup.spec.ts +++ b/test/session/dedup.spec.ts @@ -74,6 +74,19 @@ describe("Session identify dedup with userId", () => { 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("stays backward compatible: omitting userId matches legacy keys", () => { const session = new FormoAnalyticsSession(); From dbd5d3c9e071559e5a91866588e670cde8ed2826 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 03:48:43 +0000 Subject: [PATCH 07/14] fix(privy): preserve current address when active wallet isn't linked Follow-up to Codex review of the identify(user, { privy: true }) path. When the SDK's current address is a connected wallet that is NOT in user.linkedAccounts (e.g. a wagmi wallet not yet linked in Privy), the previous fix passed it as activeAddress but no linked wallet matched, so the per-wallet loop still left currentAddress on an arbitrary linked wallet. - identifyPrivyUser now returns the address of the wallet it made active (the one owning attribution), or undefined when it fell back to the heuristic because no linked wallet matched the active address. - The identify() Privy dispatch snapshots currentAddress before the sync and restores it when identifyPrivyUser returns undefined, so a connected-but- unlinked wallet keeps attribution while the linked wallets are still identified for clustering. No public setActive flag reintroduced. Added an integration test for the unlinked-connected-wallet case; 674 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- src/FormoAnalytics.ts | 16 ++++++++-- src/privy/utils.ts | 17 ++++++++-- .../identifyPrivyUser.integration.spec.ts | 31 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index b1fd71a..84c915c 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -758,13 +758,25 @@ export class FormoAnalytics implements IFormoAnalytics { activeAddress?: string; properties?: IFormoEventProperties; }; - await identifyPrivyUser(this, maybeUser as PrivyUser, { + // Snapshot the address the SDK currently attributes events to. Each + // per-wallet identify below rewrites currentAddress, so we restore this + // afterwards if the sync didn't land on a linked wallet. + const prevAddress = this.currentAddress; + const attributed = await identifyPrivyUser(this, maybeUser as PrivyUser, { // Prefer an explicit override, else the wallet the SDK already treats // as active (e.g. from a wagmi/EIP-1193 connect) so this multi-wallet // identify doesn't overwrite attribution with Privy's primary wallet. - activeAddress: opts.activeAddress ?? this.currentAddress, + activeAddress: opts.activeAddress ?? prevAddress, properties: opts.properties, }); + // When no linked wallet matched the active address (e.g. the connected + // wallet isn't linked in Privy), the loop left currentAddress on an + // arbitrary linked wallet — restore the pre-sync address so attribution + // stays on the wallet the user is actually using. + if (!attributed && prevAddress && this.currentAddress !== prevAddress) { + this.currentAddress = prevAddress; + this.persistActiveWallet(); + } return; } diff --git a/src/privy/utils.ts b/src/privy/utils.ts index ff7fb1b..17d4021 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -304,6 +304,13 @@ export interface IdentifyPrivyUserOptions { * first, purely for clustering. This is done by ordering alone — no special * `identify()` flag — so the core API is unchanged. * + * Returns the address of the linked wallet that was made active (i.e. the one + * that now owns event attribution), or `undefined` when no linked wallet + * matched the requested active address (or the user had no wallets). Callers + * that track their own current wallet can use this to detect the fallback case + * and preserve their prior address — `formo.identify(user, { privy: true })` + * does exactly that. + * * All linked wallet addresses used here come from `user.linkedAccounts`, which * is fully available on the frontend from Privy's `usePrivy()` hook. * @@ -334,8 +341,8 @@ export async function identifyPrivyUser( analytics: IFormoAnalytics, user: PrivyUser, options: IdentifyPrivyUserOptions = {} -): Promise { - if (!analytics || !user) return; +): Promise { + if (!analytics || !user) return undefined; const { properties, wallets } = parsePrivyProperties(user); @@ -347,7 +354,7 @@ export async function identifyPrivyUser( "identifyPrivyUser: user has no linked wallets; nothing to identify", user.id ); - return; + return undefined; } const baseProperties: IFormoEventProperties = { @@ -395,4 +402,8 @@ export async function identifyPrivyUser( walletProperties ); } + + // The wallet now owning attribution (identified last), or undefined if we fell + // back to the heuristic because no linked wallet matched the active address. + return activeWallet?.address; } diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index cae3b21..68eea91 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -208,6 +208,37 @@ describe("identifyPrivyUser (integration with real identify)", () => { 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); + }); + it("does not let a non-active linked wallet hijack currentAddress after a real connect", async () => { const formo = await makeAnalytics(); captureIdentifies(formo); From ab05adb9d4cbde16e71307cd5ff40d5cc49b4f18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:43:34 +0000 Subject: [PATCH 08/14] =?UTF-8?q?refactor(privy):=20root=20fix=20=E2=80=94?= =?UTF-8?q?=20clustering=20identifies=20no=20longer=20mutate=20active=20st?= =?UTF-8?q?ate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ordering + snapshot/restore approach (which kept spawning attribution edge cases) with a structural fix, per review decision. Also owns chain state for the active wallet. Root cause: the per-wallet identify loop rewrote the SDK's active identity (currentAddress / currentUserId / user-id cookie) for every wallet, so any non-active wallet could win or corrupt attribution. - identify() concrete impl gains an internal `setActive` flag (NOT on the public IFormoAnalytics.identify overloads/interface). When false, the wallet↔user link is still emitted and deduped for clustering, but currentAddress AND currentUserId/cookie are left untouched. - identifyPrivyUser emits clustering identifies with setActive:false and promotes only the resolved active wallet with setActive:true. Ordering and the dispatch snapshot/restore are gone; a connected wallet that isn't linked in Privy is now preserved structurally (no wallet is promoted), which also fixes the userId-restore gap (it's never repointed in the first place). - Active-wallet resolution: an explicit/connected activeAddress is matched strictly (no fallback), else user.wallet, else last-external heuristic. - Chain state: identifyPrivyUser returns the active wallet's { address, chainType }, and the identify(user,{privy:true}) dispatch clears currentChainId when the active wallet's chain namespace no longer matches (e.g. a Solana wallet while an EVM chain id was current), so events/cookie aren't paired with a mismatched chain. Tests reworked to assert setActive instead of loop order; added userId-preserve and Solana-chain-clear coverage. Docs updated. 675 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 47 ++++--- src/FormoAnalytics.ts | 97 +++++++++----- src/privy/utils.ts | 119 +++++++++++------- .../identifyPrivyUser.integration.spec.ts | 33 ++++- test/privy/privy.spec.ts | 53 ++++---- 5 files changed, 228 insertions(+), 121 deletions(-) diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index 6c79cbc..6372cf4 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -62,9 +62,17 @@ and pins event attribution to the active wallet. `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`, calling the normal -`identify({ address, userId })` once per linked wallet. The Privy-specific logic -lives in the SDK's Privy module; the core `identify()` just dispatches to it. +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 @@ -139,28 +147,27 @@ one, and an Ethereum wallet apart from a Solana one, in your analytics. ## 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. Since `identify()` also updates the SDK's -"current address" (the wallet later events are attributed to), naively looping -over every linked wallet would leave attribution on whichever wallet happened to -be identified last. - -The SDK handles this by **ordering** the loop — it identifies the chosen wallet -last, so it's the one that ends up as the current address. The wallet is chosen, -in order: +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); + 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 order: embedded (Privy) wallets first, attributing to the - last external wallet. - -In practice you can just call `formo.identify(user, { privy: true })` and let it -default to Privy's primary wallet. Pass `activeAddress` only when you want to pin -attribution to a specific wallet. If you already track the connected wallet via a -separate `connect()`/wagmi flow, that remains the source of truth for attribution -once the user transacts. +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 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 84c915c..f604d45 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -758,30 +758,35 @@ export class FormoAnalytics implements IFormoAnalytics { activeAddress?: string; properties?: IFormoEventProperties; }; - // Snapshot the address the SDK currently attributes events to. Each - // per-wallet identify below rewrites currentAddress, so we restore this - // afterwards if the sync didn't land on a linked wallet. - const prevAddress = this.currentAddress; - const attributed = await identifyPrivyUser(this, maybeUser as PrivyUser, { - // Prefer an explicit override, else the wallet the SDK already treats - // as active (e.g. from a wagmi/EIP-1193 connect) so this multi-wallet - // identify doesn't overwrite attribution with Privy's primary wallet. - activeAddress: opts.activeAddress ?? prevAddress, + // identifyPrivyUser records every linked wallet for clustering WITHOUT + // touching active state (internal setActive:false), and promotes only + // the resolved active wallet. Prefer an explicit override, else the + // wallet the SDK already treats as active (e.g. from a wagmi/EIP-1193 + // connect). Passing that address means a connected wallet that isn't + // linked in Privy simply doesn't match, so the sync leaves attribution + // untouched instead of overwriting it — no snapshot/restore needed. + const active = await identifyPrivyUser(this, maybeUser as PrivyUser, { + activeAddress: opts.activeAddress ?? this.currentAddress, properties: opts.properties, }); - // When no linked wallet matched the active address (e.g. the connected - // wallet isn't linked in Privy), the loop left currentAddress on an - // arbitrary linked wallet — restore the pre-sync address so attribution - // stays on the wallet the user is actually using. - if (!attributed && prevAddress && this.currentAddress !== prevAddress) { - this.currentAddress = prevAddress; - this.persistActiveWallet(); - } + // Reconcile the chain id with the newly-active wallet's chain namespace + // so a Solana address isn't left paired with a stale EVM chain id. + if (active) this.syncPrivyActiveChain(active.chainType); return; } const params = paramsOrUser as - | { address: Address; providerName?: string; userId?: string; rdns?: string } + | { + 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; @@ -862,7 +867,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) { @@ -873,21 +878,28 @@ 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. The userId is @@ -938,6 +950,29 @@ 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. + */ + private 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 diff --git a/src/privy/utils.ts b/src/privy/utils.ts index 17d4021..793c6de 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -298,18 +298,18 @@ export interface IdentifyPrivyUserOptions { * `is_embedded` metadata. Because every wallet is tagged with the same Privy * `userId`, Formo can cluster them server-side into a single user. * - * Attribution: the active/connected wallet (see - * {@link IdentifyPrivyUserOptions.activeAddress}) is identified last so it ends - * up as the SDK's current address; the other linked wallets are identified - * first, purely for clustering. This is done by ordering alone — no special - * `identify()` flag — so the core API is unchanged. + * 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. * - * Returns the address of the linked wallet that was made active (i.e. the one - * that now owns event attribution), or `undefined` when no linked wallet - * matched the requested active address (or the user had no wallets). Callers - * that track their own current wallet can use this to detect the fallback case - * and preserve their prior address — `formo.identify(user, { privy: true })` - * does exactly that. + * 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). The + * `formo.identify(user, { privy: true })` form uses the returned `chainType` to + * reconcile the SDK's chain id with the active wallet. * * All linked wallet addresses used here come from `user.linkedAccounts`, which * is fully available on the frontend from Privy's `usePrivy()` hook. @@ -341,7 +341,7 @@ export async function identifyPrivyUser( analytics: IFormoAnalytics, user: PrivyUser, options: IdentifyPrivyUserOptions = {} -): Promise { +): Promise<{ address: string; chainType?: string } | undefined> { if (!analytics || !user) return undefined; const { properties, wallets } = parsePrivyProperties(user); @@ -362,34 +362,22 @@ export async function identifyPrivyUser( ...options.properties, }; - // Resolve the wallet that should own event attribution: an explicit - // activeAddress wins, otherwise fall back to Privy's own surfaced wallet - // (`user.wallet`) — its designated primary — so callers don't have to pass - // anything. Only honored if it matches one of the linked wallets (using - // chain-appropriate address comparison, so Solana casing isn't mismatched). - const activeAddress = options.activeAddress ?? user.wallet?.address; - const activeWallet = activeAddress - ? wallets.find((w) => sameAddress(w.address, activeAddress)) - : undefined; - - // Order embedded (Privy) wallets first and external wallets last; when the - // active wallet is known, move it to the very end. identify() is called in - // this order and each call updates the SDK's current address, so the last - // wallet identified — the active one, or the last external wallet as a - // best-effort fallback — wins event attribution. No change to the core - // identify() API is needed: ordering alone keeps attribution off an - // arbitrary linked wallet. - const embedded = wallets.filter((w) => w.isEmbedded); - const external = wallets.filter((w) => !w.isEmbedded); - let ordered: PrivyWalletInfo[] = [...embedded, ...external]; - if (activeWallet) { - ordered = [ - ...ordered.filter((w) => w !== activeWallet), - activeWallet, - ]; - } + const activeWallet = resolveActiveWallet( + wallets, + options.activeAddress, + user.wallet?.address + ); - for (const wallet of ordered) { + // 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 = analytics.identify.bind( + analytics + ) as unknown as InternalIdentifyFn; + for (const wallet of wallets) { const walletProperties: IFormoEventProperties = { ...baseProperties, is_embedded: wallet.isEmbedded, @@ -397,13 +385,56 @@ export async function identifyPrivyUser( if (wallet.walletClient) walletProperties.wallet_client = wallet.walletClient; if (wallet.chainType) walletProperties.chain_type = wallet.chainType; - await analytics.identify( - { address: wallet.address, userId: user.id }, + await identify( + { + address: wallet.address, + userId: user.id, + setActive: wallet === activeWallet, + }, walletProperties ); } - // The wallet now owning attribution (identified last), or undefined if we fell - // back to the heuristic because no linked wallet matched the active address. - return activeWallet?.address; + return activeWallet + ? { address: activeWallet.address, chainType: activeWallet.chainType } + : undefined; +} + +/** + * The internal shape of `identify()` including the `setActive` flag that is not + * part of the public {@link IFormoAnalytics} contract. `identifyPrivyUser` uses + * it to record clustering identifies without repointing attribution. + */ +type InternalIdentifyFn = ( + params: { address: string; userId?: string; setActive?: boolean }, + properties?: IFormoEventProperties +) => Promise; + +/** + * 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/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index 68eea91..2e65655 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -159,7 +159,7 @@ describe("identifyPrivyUser (integration with real identify)", () => { expect(e.properties.email).to.equal("user@example.com"); expect(e.properties).to.have.property("is_embedded"); } - // Active wallet identified last → owns attribution. + // Only the active wallet (setActive) owns attribution. expect(formo.currentAddress?.toLowerCase()).to.equal(EXTERNAL); }); @@ -237,6 +237,37 @@ describe("identifyPrivyUser (integration with real identify)", () => { // ...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("does not let a non-active linked wallet hijack currentAddress after a real connect", async () => { diff --git a/test/privy/privy.spec.ts b/test/privy/privy.spec.ts index cdb61fe..6b5ddba 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -11,12 +11,18 @@ 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. @@ -413,7 +419,7 @@ describe("Privy Utilities", () => { expect(props.is_embedded).to.equal(false); }); - it("attributes to the provided active wallet and identifies it last", async () => { + it("promotes the provided active wallet (setActive) and records the rest for clustering", async () => { const user: PrivyUser = { id: "did:privy:abc123", linkedAccounts: [ @@ -426,18 +432,20 @@ describe("Privy Utilities", () => { await identifyPrivyUser(analytics, user, { activeAddress: EXTERNAL }); - // Active wallet is identified last, so it wins attribution (identify() - // sets the current address on each call, last write wins). - expect(calls[calls.length - 1].params.address).to.equal(EXTERNAL); - // ...and every wallet is still identified for clustering. + // 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", async () => { + it("matches the active wallet case-insensitively (EVM)", async () => { const user: PrivyUser = { id: "did:privy:abc123", linkedAccounts: [ @@ -451,11 +459,10 @@ describe("Privy Utilities", () => { activeAddress: EMBEDDED.toUpperCase(), }); - // Case-insensitive match still puts the active wallet last. - expect(calls[calls.length - 1].params.address).to.equal(EMBEDDED); + expect(activeAddressOf(calls)).to.equal(EMBEDDED); }); - it("falls back to embedded-first order and attributes to the last external wallet", async () => { + it("falls back to the last external wallet when no active address or user.wallet", async () => { const user: PrivyUser = { id: "did:privy:abc123", linkedAccounts: [ @@ -467,10 +474,8 @@ describe("Privy Utilities", () => { await identifyPrivyUser(analytics, user); - // Embedded wallet is identified first... - expect(calls[0].params.address).to.equal(EMBEDDED); - // ...and the external wallet is identified last, so it owns attribution. - expect(calls[1].params.address).to.equal(EXTERNAL); + // 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 () => { @@ -487,9 +492,8 @@ describe("Privy Utilities", () => { await identifyPrivyUser(analytics, user); - // user.wallet (EMBEDDED) is treated as active → identified last, - // overriding the embedded-first heuristic (which would end on EXTERNAL). - expect(calls[calls.length - 1].params.address).to.equal(EMBEDDED); + // 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 () => { @@ -505,8 +509,7 @@ describe("Privy Utilities", () => { await identifyPrivyUser(analytics, user, { activeAddress: EXTERNAL }); - // Explicit override wins over user.wallet. - expect(calls[calls.length - 1].params.address).to.equal(EXTERNAL); + expect(activeAddressOf(calls)).to.equal(EXTERNAL); }); it("compares Solana active addresses case-sensitively (no false match)", async () => { @@ -516,25 +519,25 @@ describe("Privy Utilities", () => { const user: PrivyUser = { id: "did:privy:abc123", linkedAccounts: [ - // embedded Solana wallet (would be identified first by the heuristic) { type: "wallet", address: SOL, walletClientType: "privy", chainType: "solana" }, - // external EVM wallet (heuristic attributes here, last) { type: "wallet", address: EXTERNAL, walletClientType: "metamask", chainType: "ethereum" }, ], }; - // Wrong-case Solana address must NOT match → attribution falls back to the - // heuristic (external EXTERNAL last), not the mis-cased SOL wallet. + // 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(miss.calls[miss.calls.length - 1].params.address).to.equal(EXTERNAL); + expect(activeAddressOf(miss.calls)).to.equal(undefined); + expect(miss.calls).to.have.length(2); - // Exact-case Solana address IS accepted → moved last. + // Exact-case Solana address IS accepted → promoted. const hit = makeRecorder(); await identifyPrivyUser(hit.analytics, user, { activeAddress: SOL }); - expect(hit.calls[hit.calls.length - 1].params.address).to.equal(SOL); + expect(activeAddressOf(hit.calls)).to.equal(SOL); }); it("merges options.properties into every identify call", async () => { From 5c332370764b18eafe88884f10f8f9fcdf4f2fcf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:08:14 +0000 Subject: [PATCH 09/14] docs(privy): add integration plan/status doc; fix stale activeAddress JSDoc - Add docs/privy-identity-integration-plan.md: design/status doc covering the clustering approach, the gaps closed, the attribution model (internal setActive), Privy-docs validation, resolved review findings, and the deferred Phase 2 items. Complements the usage-focused PRIVY_INTEGRATION.md. - Fix IdentifyPrivyUserOptions.activeAddress JSDoc that still described the old ordering ("identified last"); it now reflects the setActive promotion + strict matching / preservation semantics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/privy-identity-integration-plan.md | 132 ++++++++++++++++++++++++ src/privy/utils.ts | 17 +-- 2 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 docs/privy-identity-integration-plan.md diff --git a/docs/privy-identity-integration-plan.md b/docs/privy-identity-integration-plan.md new file mode 100644 index 0000000..6eb6cf7 --- /dev/null +++ b/docs/privy-identity-integration-plan.md @@ -0,0 +1,132 @@ +# 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:** the dispatch 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), so events / the active‑wallet cookie can't + pair an address with the wrong chain. + +### 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. + +### Verification + +675 tests passing (unit + real‑`identify()` integration + 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/privy/utils.ts b/src/privy/utils.ts index 793c6de..b19e713 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -262,16 +262,19 @@ export function parsePrivyProperties(user: PrivyUser): { export interface IdentifyPrivyUserOptions { /** * Optional override for the wallet that should own event attribution — the - * one identified last, so later events are attributed to it. + * 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 defaults to Privy's - * own surfaced wallet (`user.wallet`), then to a best-effort order (embedded - * wallets first, attributing to 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` or your wagmi account, - * which reflects the live active wallet more precisely than `user.wallet`. + * own surfaced wallet (`user.wallet`), then to 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` or your wagmi account, which reflects + * the live active wallet more precisely than `user.wallet`. * - * Ignored if it doesn't match one of the user's linked wallets. + * 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; From 2581432b8e8e3a8e3d03b56b445817022fb6a2e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 07:12:24 +0000 Subject: [PATCH 10/14] fix(privy): reconcile chain before emitting; disambiguate dedup keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review of 5c33237 (3 × P2). - Chain reconciliation moved into identifyPrivyUser, BEFORE the emit loop (previously in the dispatch, after). identify() runs each event through the tracking gate, which enforces excludeChains against the current chain id; if the stale chain was excluded, every clustering identify was dropped and the Privy identity silently lost. Reconciling first fixes that, and doing it in the helper means the direct identifyPrivyUser() entry point reconciles too (the two forms are now truly equivalent, as the docs claim). syncPrivyActiveChain is now an internal (non-public) method invoked via a structural cast. - Session dedup keys: when a userId is present, always emit the rdns slot (even empty), so a userId equal to a provider RDNS (e.g. "io.metamask") can't collide with an anonymous address:rdns key and get wrongly deduped. No-userId keys are unchanged (backward compatible). Added tests: identifies still emit when on an excluded chain but activating a Solana wallet; the direct helper reconciles chain; userId==rdns doesn't collide. Docs/JSDoc updated. 678 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/privy-identity-integration-plan.md | 9 +-- src/FormoAnalytics.ts | 24 ++++---- src/privy/utils.ts | 46 ++++++++++----- src/session/index.ts | 13 ++++- .../identifyPrivyUser.integration.spec.ts | 56 ++++++++++++++++++- test/session/dedup.spec.ts | 11 ++++ 6 files changed, 128 insertions(+), 31 deletions(-) diff --git a/docs/privy-identity-integration-plan.md b/docs/privy-identity-integration-plan.md index 6eb6cf7..7ba7071 100644 --- a/docs/privy-identity-integration-plan.md +++ b/docs/privy-identity-integration-plan.md @@ -73,10 +73,11 @@ the SDK's "current address" (what later `track()`/`page()` events attribute to). - 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:** the dispatch 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), so events / the active‑wallet cookie can't - pair an address with the wrong chain. +- **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 diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index f604d45..bcd4f06 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -759,19 +759,16 @@ export class FormoAnalytics implements IFormoAnalytics { properties?: IFormoEventProperties; }; // identifyPrivyUser records every linked wallet for clustering WITHOUT - // touching active state (internal setActive:false), and promotes only - // the resolved active wallet. Prefer an explicit override, else the - // wallet the SDK already treats as active (e.g. from a wagmi/EIP-1193 - // connect). Passing that address means a connected wallet that isn't - // linked in Privy simply doesn't match, so the sync leaves attribution - // untouched instead of overwriting it — no snapshot/restore needed. - const active = await identifyPrivyUser(this, maybeUser as PrivyUser, { + // touching active state (internal setActive:false), promotes only the + // resolved active wallet, and reconciles the chain id with that wallet's + // namespace before emitting. Prefer an explicit override, else the wallet + // the SDK already treats as active (e.g. from a wagmi/EIP-1193 connect); + // a connected wallet that isn't linked in Privy simply doesn't match, so + // the sync leaves attribution untouched — no snapshot/restore needed. + await identifyPrivyUser(this, maybeUser as PrivyUser, { activeAddress: opts.activeAddress ?? this.currentAddress, properties: opts.properties, }); - // Reconcile the chain id with the newly-active wallet's chain namespace - // so a Solana address isn't left paired with a stale EVM chain id. - if (active) this.syncPrivyActiveChain(active.chainType); return; } @@ -961,8 +958,13 @@ export class FormoAnalytics implements IFormoAnalytics { * 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. */ - private syncPrivyActiveChain(chainType?: string): void { + syncPrivyActiveChain(chainType?: string): void { if (this.currentChainId === undefined || this.currentChainId === null) return; if (!chainType) return; const walletIsSolana = chainType.toLowerCase() === "solana"; diff --git a/src/privy/utils.ts b/src/privy/utils.ts index b19e713..f58f629 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -308,11 +308,16 @@ export interface IdentifyPrivyUserOptions { * 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). The - * `formo.identify(user, { privy: true })` form uses the returned `chainType` to - * reconcile the SDK's chain id with the active wallet. + * 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. @@ -371,15 +376,25 @@ export async function identifyPrivyUser( user.wallet?.address ); + const target = analytics as unknown as PrivySyncTarget; + + // 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 = analytics.identify.bind( - analytics - ) as unknown as InternalIdentifyFn; + const identify = target.identify.bind(analytics); for (const wallet of wallets) { const walletProperties: IFormoEventProperties = { ...baseProperties, @@ -404,14 +419,19 @@ export async function identifyPrivyUser( } /** - * The internal shape of `identify()` including the `setActive` flag that is not - * part of the public {@link IFormoAnalytics} contract. `identifyPrivyUser` uses - * it to record clustering identifies without repointing attribution. + * 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), and `syncPrivyActiveChain` (reconcile the chain id with the + * active wallet's namespace). Both are optional so a minimal stub still works. */ -type InternalIdentifyFn = ( - params: { address: string; userId?: string; setActive?: boolean }, - properties?: IFormoEventProperties -) => Promise; +interface PrivySyncTarget { + identify: ( + params: { address: string; userId?: string; setActive?: boolean }, + properties?: IFormoEventProperties + ) => Promise; + syncPrivyActiveChain?(chainType?: string): void; +} /** * Choose the linked wallet that should own event attribution. diff --git a/src/session/index.ts b/src/session/index.ts index 288b381..5c2cee4 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -94,8 +94,17 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { // characters, so their encoded form is unchanged — existing stored keys // still match (backward compatible). const parts = [encodeURIComponent(address)]; - if (rdns) parts.push(encodeURIComponent(rdns)); - if (userId) parts.push(encodeURIComponent(userId)); + 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(":"); } diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index 2e65655..30b52e2 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -24,7 +24,9 @@ describe("identifyPrivyUser (integration with real identify)", () => { const EXTERNAL_2 = "0x3333333333333333333333333333333333333333"; const DID = "did:privy:integration"; - async function makeAnalytics(): Promise { + 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(() => {}), @@ -38,6 +40,7 @@ describe("identifyPrivyUser (integration with real identify)", () => { }; return FormoAnalytics.init("test-write-key", { wagmi: { config: mockWagmiConfig as any }, + ...(tracking !== undefined ? { tracking: tracking as any } : {}), }); } @@ -270,6 +273,57 @@ describe("identifyPrivyUser (integration with real identify)", () => { 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); diff --git a/test/session/dedup.spec.ts b/test/session/dedup.spec.ts index d75311b..70de7cb 100644 --- a/test/session/dedup.spec.ts +++ b/test/session/dedup.spec.ts @@ -87,6 +87,17 @@ describe("Session identify dedup with userId", () => { expect(session.isWalletIdentified(ADDRESS, "", commaUser)).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(); From 594dc71de82122164527fcd8559c8522fe989201 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 07:23:06 +0000 Subject: [PATCH 11/14] fix(privy): size-bound the identify dedup store for many-wallet users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review of 2581432 (P2). The identified-wallet session store used a fixed 20-entry cap, so a Privy user with more than 20 linked wallets evicted earlier (wallet, DID) keys; a later sync then re-emitted the evicted identities, defeating the per-session dedup. Replace the fixed entry-count cap with a serialized-size bound: keep every identity that fits in the cookie and evict oldest only when the value would overflow (~3.5KB, safely under the ~4KB per-cookie limit — room for ~40 identities, beyond any realistic linked-wallet set). Chosen over a bigger magic cap (still bounded by cookie size) and over hashing keys (which would trade a harmless re-emit for a silent collision-drop of an identity link). The detected store keeps its small count cap. Added a test asserting 40 identities all remain recognized (past the old limit). 679 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- src/session/index.ts | 28 ++++++++++++++++++++++++---- test/session/dedup.spec.ts | 17 +++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/session/index.ts b/src/session/index.ts index 5c2cee4..d258220 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -59,11 +59,24 @@ 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 @@ -186,10 +199,17 @@ export class FormoAnalyticsSession implements IFormoAnalyticsSession { 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/test/session/dedup.spec.ts b/test/session/dedup.spec.ts index 70de7cb..e95d21f 100644 --- a/test/session/dedup.spec.ts +++ b/test/session/dedup.spec.ts @@ -87,6 +87,23 @@ describe("Session identify dedup with userId", () => { 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". From 1aab64170cf25c76bdd02c66c7089ab9132ce1e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 07:34:06 +0000 Subject: [PATCH 12/14] fix(privy): direct identifyPrivyUser() preserves the connected wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review of 594dc71 (P2). The direct identifyPrivyUser(formo, user) form (no activeAddress) fell back to user.wallet and overwrote attribution on an already-connected wallet, while the identify(user,{privy:true}) dispatch passed this.currentAddress first — so the two forms diverged despite being documented as equivalent. Move the currentAddress fallback INTO the helper: it reads the instance's currentAddress (via the existing internal cast) before user.wallet, so both entry points preserve a connected wallet identically. The dispatch is now a thin pass-through of opts.activeAddress. Unmatched connected wallets are still preserved (strict match, no promotion); stubs without currentAddress fall through to user.wallet as before. Added a direct-form preservation test. 680 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- src/FormoAnalytics.ts | 9 ++++--- src/privy/utils.ts | 18 ++++++++++---- .../identifyPrivyUser.integration.spec.ts | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index bcd4f06..3545bba 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -761,12 +761,11 @@ export class FormoAnalytics implements IFormoAnalytics { // 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. Prefer an explicit override, else the wallet - // the SDK already treats as active (e.g. from a wagmi/EIP-1193 connect); - // a connected wallet that isn't linked in Privy simply doesn't match, so - // the sync leaves attribution untouched — no snapshot/restore needed. + // 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 ?? this.currentAddress, + activeAddress: opts.activeAddress, properties: opts.properties, }); return; diff --git a/src/privy/utils.ts b/src/privy/utils.ts index f58f629..6fa1cb0 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -370,14 +370,20 @@ export async function identifyPrivyUser( ...options.properties, }; + const target = analytics as unknown as PrivySyncTarget; + + // 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, + options.activeAddress ?? target.currentAddress, user.wallet?.address ); - const target = analytics as unknown as PrivySyncTarget; - // 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 @@ -422,10 +428,12 @@ export async function identifyPrivyUser( * 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), and `syncPrivyActiveChain` (reconcile the chain id with the - * active wallet's namespace). Both are optional so a minimal stub still works. + * attribution), `syncPrivyActiveChain` (reconcile the chain id with the active + * wallet's namespace), 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 diff --git a/test/privy/identifyPrivyUser.integration.spec.ts b/test/privy/identifyPrivyUser.integration.spec.ts index 30b52e2..7b7fc14 100644 --- a/test/privy/identifyPrivyUser.integration.spec.ts +++ b/test/privy/identifyPrivyUser.integration.spec.ts @@ -211,6 +211,30 @@ describe("identifyPrivyUser (integration with real identify)", () => { 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); From b026ba6388022e2c2a9c62d0bfd972c74f3e863d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 08:34:58 +0000 Subject: [PATCH 13/14] fix(privy): carry the DID on every clustering identify; skip sync when suppressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s from Codex review of 1aab641. 1. Clustering events lost the Privy DID. EventFactory.create() unconditionally overwrote each identify event's payload user_id with the active-session user id. Because setActive:false clustering identifies intentionally do NOT change currentUserId, every non-active wallet's identify was emitted with user_id: null (or a stale user), so only the active wallet clustered under the DID — silently breaking the whole feature. Fix: identify events keep their payload user_id (the per-wallet DID), falling back to the session user id only when the payload carries none. Other events are unchanged. This also preserves the WTo behavior (session currentUserId is still not repointed). 2. Chain reconciliation ran for suppressed visitors. identifyPrivyUser reconciles chain BEFORE the inner identifies reach their suppression guard, so on an excluded host/path (or after opt-out) it cleared currentChainId while no identify actually ran — leaving later events on an allowed route unable to apply excludeChains. Fix: gate the whole helper on isTrackingSuppressed (via the internal cast), covering both entry points. Added tests: EventFactory.create keeps the identify payload DID over/without the session user id; identifyPrivyUser emits nothing and does not reconcile chain when suppressed. 685 passing, lint + build clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- src/FormoAnalytics.ts | 4 +- src/event/EventFactory.ts | 12 ++- src/privy/utils.ts | 17 ++- test/lib/event/EventFactoryUserId.spec.ts | 120 ++++++++++++++++++++++ test/privy/privy.spec.ts | 29 ++++++ 5 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 test/lib/event/EventFactoryUserId.spec.ts diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 3545bba..61c78e1 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -1998,8 +1998,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/event/EventFactory.ts b/src/event/EventFactory.ts index 5aafc50..6f42986 100644 --- a/src/event/EventFactory.ts +++ b/src/event/EventFactory.ts @@ -801,7 +801,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/utils.ts b/src/privy/utils.ts index 6fa1cb0..4f367a0 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -352,6 +352,15 @@ export async function identifyPrivyUser( ): 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 @@ -370,8 +379,6 @@ export async function identifyPrivyUser( ...options.properties, }; - const target = analytics as unknown as PrivySyncTarget; - // 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 @@ -429,8 +436,9 @@ export async function identifyPrivyUser( * 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), and read access to `currentAddress` (the wallet Formo - * already treats as active). All optional so a minimal stub still works. + * 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; @@ -439,6 +447,7 @@ interface PrivySyncTarget { properties?: IFormoEventProperties ) => Promise; syncPrivyActiveChain?(chainType?: string): void; + isTrackingSuppressed?(): boolean; } /** 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/privy.spec.ts b/test/privy/privy.spec.ts index 6b5ddba..3b949f7 100644 --- a/test/privy/privy.spec.ts +++ b/test/privy/privy.spec.ts @@ -572,5 +572,34 @@ describe("Privy Utilities", () => { 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); + }); }); }); From 6fe2370a621447cdea4d54a726532d9d0a48214d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 08:41:36 +0000 Subject: [PATCH 14/14] docs(privy): fix stale attribution docs; drop dead export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review pass over the branch. No runtime bugs found; the issues were documentation left behind by the ordering -> setActive refactor, plus one dead export: - identify() @example and the IFormoAnalytics Privy-overload JSDoc still described the removed mechanisms ("identifies the active wallet last", "defaults to user.wallet") — both now state the real resolution order (explicit activeAddress -> connected wallet -> user.wallet) and the clustering-only nature of non-active identifies. - IdentifyPrivyUserOptions.activeAddress JSDoc was missing the connected-wallet fallback step. - PRIVY_INTEGRATION.md still claimed wallets are "identified in a deliberate order — the active wallet last". - isPrivyWalletAccount was exported solely for the deleted React hook; made module-private (it was never re-exported from the package entries, so no public API change). - Plan doc: resolved-findings list updated with the last four review rounds; test count refreshed (685). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq --- docs/PRIVY_INTEGRATION.md | 5 +++-- docs/privy-identity-integration-plan.md | 20 ++++++++++++++++++-- src/FormoAnalytics.ts | 5 +++-- src/privy/utils.ts | 19 +++++++++---------- src/types/base.ts | 6 ++++-- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/docs/PRIVY_INTEGRATION.md b/docs/PRIVY_INTEGRATION.md index 6372cf4..99c461b 100644 --- a/docs/PRIVY_INTEGRATION.md +++ b/docs/PRIVY_INTEGRATION.md @@ -123,8 +123,9 @@ formo.identify( ); ``` -The wallets are identified in a deliberate order — the active wallet last — so -event attribution lands on it rather than an arbitrary linked wallet (see below). +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 diff --git a/docs/privy-identity-integration-plan.md b/docs/privy-identity-integration-plan.md index 7ba7071..458683b 100644 --- a/docs/privy-identity-integration-plan.md +++ b/docs/privy-identity-integration-plan.md @@ -102,11 +102,27 @@ the SDK's "current address" (what later `track()`/`page()` events attribute to). - 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 -675 tests passing (unit + real‑`identify()` integration + session dedup); lint -and full build clean. +685 tests passing (unit + real‑`identify()` integration + `EventFactory.create` +user‑id resolution + session dedup); lint and full build clean. ## Phase 2 — deferred (product / backend) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 61c78e1..a7a782e 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -693,8 +693,9 @@ export class FormoAnalytics implements IFormoAnalytics { * formo.identify({ address: '0x...', userId: 'user123' }); * * // Privy: pass the usePrivy() user with `{ privy: true }` to identify every - * // linked wallet under the user's DID in one call. Attribution defaults to - * // Privy's primary wallet (user.wallet); pass `activeAddress` to override. + * // 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) formo.identify(user, { privy: true }); * ``` diff --git a/src/privy/utils.ts b/src/privy/utils.ts index 4f367a0..410927f 100644 --- a/src/privy/utils.ts +++ b/src/privy/utils.ts @@ -14,11 +14,9 @@ import { logger } from "../logger"; /** * Whether a Privy linked account is a usable wallet — an EVM/Solana wallet or - * smart wallet with an address. Shared by {@link parsePrivyProperties} and the - * React binding so the "which accounts are wallets" rule can't drift between - * them. + * smart wallet with an address. */ -export function isPrivyWalletAccount(account: PrivyLinkedAccount): boolean { +function isPrivyWalletAccount(account: PrivyLinkedAccount): boolean { return ( (account.type === "wallet" || account.type === "smart_wallet") && !!account.address @@ -265,12 +263,13 @@ export interface IdentifyPrivyUserOptions { * 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 defaults to Privy's - * own surfaced wallet (`user.wallet`), then to 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` or your wagmi account, which reflects - * the live active wallet more precisely than `user.wallet`. + * 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 diff --git a/src/types/base.ts b/src/types/base.ts index c4f3576..ea18113 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -93,8 +93,10 @@ export interface IFormoAnalytics { /** * 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`, forwards each - * wallet's metadata, and identifies the active wallet last for attribution. + * 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,