Skip to content

Add Privy integration for multi-wallet user identity clustering - #304

Open
yosriady wants to merge 19 commits into
mainfrom
claude/privy-identify-one-liner-6rovf1
Open

Add Privy integration for multi-wallet user identity clustering#304
yosriady wants to merge 19 commits into
mainfrom
claude/privy-identify-one-liner-6rovf1

Conversation

@yosriady

@yosriady yosriady commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

The problem

A Privy user is one account (a DID) with many linked wallets — an embedded wallet plus any external wallets they connect over time. Formo's analytics is address-keyed, so one person with 8 wallets becomes 8 Formo users. Retention, conversion, and user counts are all wrong, and no single journey is visible.

The approach

Tag every linked wallet with the same Privy userId (the DID) via identify(). Because the wallets share a userId, existing server-side clustering merges them — no alias() API needed.

Why one identify per wallet rather than one carrying a wallet array: the wallet profiler is address-keyed and needs an event per address to create a profile and bind it to the anonymous_id.

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 the flag delegates to. Returns the active wallet's { address, chainType }.
parsePrivyProperties(user){ properties, wallets } Low-level parse, for custom flows.

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, linkAccount, and unlinkAccount.

Attribution — the core design

Every linked wallet is identified for clustering, but only one 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. setActive: false emits and dedupes the wallet↔user link but does not touch currentAddress, currentUserId, or the user-id cookie.
  • Active-wallet resolution: activeAddress (matched strictly) → the SDK's already-connected wallet → user.wallet → last-external heuristic.
  • A connected wallet that isn't linked in Privy is preserved: an unmatched activeAddress promotes nothing, leaving the current address untouched.

EventFactory.create() now keeps an identify event's payload user_id instead of overwriting it with the active-session user id. Without this, every setActive: false event was stripped of its DID — silently breaking clustering for exactly the wallets this feature exists to link.

What each wallet sends

identify(
  { address, userId: user.id },
  { ...profileProperties, wallet_client, chain_type, is_embedded },
)

profileProperties are parsed from linkedAccounts: privyDid, privyCreatedAt, email, phone, socials (X, Twitch, Discord, GitHub, Farcaster, Google, …), and customUserId. Account-set summaries (wallet counts, type lists) are deliberately not sent — they're derivable server-side, and being shared across wallets they'd make every link/unlink re-emit every wallet.

Wallets are wallet and smart_wallet accounts plus a cross_app account's embeddedWallets/smartWallets (e.g. Abstract Global Wallet), which carry addresses in arrays rather than a top-level address and were previously dropped entirely. Deduplicated by address — case-insensitively for EVM, exactly for Solana — preferring a real wallet entry over a cross_app placeholder regardless of account ordering.

Dedup is profile-aware

Key: (address, rdns, userId, hash(properties)).

The properties fingerprint is what makes account linking work. Linking a social account leaves the wallets and the DID untouched, so without it every already-identified wallet deduped and the new property never reached Formo until the session expired.

Writing a key supersedes that identity's previous entry rather than appending, so dedup means "same as this wallet's last identify". A profile that reverts (link then unlink) re-emits, and the cookie stays at one entry per wallet-user instead of growing one per profile change.

The fingerprint is total: BigInt, functions, symbols, invalid Dates, NaN/Infinity, Map/Set and cycles canonicalize rather than throw; toJSON() is honored so wire-distinct values (a changed URL) don't collide; undefined stays distinct from null; and a throwing getter or exotic Proxy degrades to pre-fingerprint dedup rather than losing the identify. It runs after active state is mutated, so a throw would otherwise leave mutated identity with no emitted event.

Key shapes are fixed by component count (1 address, 2 address:rdns, 3 +userId, 4 +hash) so they can't collide, and shapes 1–2 are byte-identical to pre-userId keys — existing browser sessions still match.

Note

This dedup change is global, not Privy-scoped: any identify() passing changed properties now re-emits. That is deliberate — it's the correct semantics — but callers passing a volatile value (a timestamp, a random id) will emit one identify per call instead of one per session. Documented in docs/PRIVY_INTEGRATION.md.

Chain reconciliation

identifyPrivyUser reconciles currentChainId with the active wallet's namespace before emitting, so identifies aren't dropped by an excludeChains gate on a stale chain id. The EVM namespace is inferred from a 0x address when Privy omits chainType (smart_wallet and cross_app entries carry none), and only "solana"/"ethereum" are treated as known — an unrecognized namespace leaves the chain id alone rather than guessing.

Known limitation (pre-existing, out of scope)

BigInt and circular values in properties still fail at the event queue's native JSON.stringify, and because the wallet is dedup-marked before emitting, that identify is suppressed for the session. This affects track() equally and predates this branch; fixing it means changing payload semantics SDK-wide.

Deferred to Phase 2 (needs ingest work)

  • Walletless users. identify() is address-keyed, so a Privy user with no linked wallet is a logged no-op. Needs a userId-keyed identify on ingest — which is also the real fix for copying user-level traits onto every wallet.
  • Unlink semantics. Links are additive server-side; there is no retraction event.
  • Users clustering surface. A /users tab keyed on user_id.

Verification

739 tests passing (unit + real-identify() integration + EventFactory.create user-id resolution + session dedup covering link/unlink re-emit, profile reversion, and hostile property values). Lint, tsc, and full build clean.

Reviewed across four adversarial Codex passes; all findings resolved except the pre-existing serialization limitation noted above.

Companion PR: getformo/examples#129 adds account linking to the with-privy example (draft — blocked until this ships as 1.34.0).


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

claude added 5 commits July 13, 2026 08:42
…llets

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
…er defaults

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
…by ordering

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
…ptional

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
@yosriady

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b3f8ad1b09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/FormoAnalytics.ts
Comment on lines +741 to +744
if (
propertiesOrOptions &&
(propertiesOrOptions as { privy?: unknown }).privy === true
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't treat event properties as Privy options

Because IFormoEventProperties is Record<string, unknown>, a normal identify call can legitimately pass a property named privy. In that case, e.g. formo.identify({ address, userId }, { privy: true }), this guard takes the Privy branch, casts the address-shaped params to PrivyUser, and identifyPrivyUser finds no linkedAccounts, so the identify event is skipped entirely instead of recording the wallet/user. Please distinguish the overload by also validating that the first argument is a Privy user, or otherwise avoid using an un-namespaced property key as the dispatch signal.

Useful? React with 👍 / 👎.

Comment thread src/privy/utils.ts Outdated
Comment on lines +347 to +349
const activeAddress = (
options.activeAddress ?? user.wallet?.address
)?.toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't default attribution to user.wallet

When callers use the documented one-liner without activeAddress, this treats user.wallet as the active wallet and moves it to the final identify() call. In sessions where the SDK already tracks a connected external wallet but Privy's user.wallet is a different first/primary wallet (commonly the embedded wallet), the later identify overwrites currentAddress, so subsequent events are attributed to the wrong wallet unless every integration passes activeAddress. Prefer preserving the already-active SDK address when it is linked, or requiring/passing the connected wallet instead of defaulting to user.wallet.

Useful? React with 👍 / 👎.

Comment thread src/privy/utils.ts Outdated
Comment on lines +350 to +352
const hasActive =
!!activeAddress &&
wallets.some((w) => w.address.toLowerCase() === activeAddress);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Solana casing when matching active wallets

When a Privy user has Solana linked wallets and the caller passes an activeAddress that differs only by case (or links two Base58 addresses that collide after lowercasing), this case-insensitive match accepts the wrong wallet even though Solana addresses are case-sensitive; the later ordering can then move/drop the wrong entry and leave attribution on the wrong address. Compare EVM addresses case-insensitively only after detecting they are EVM, and keep Solana comparisons exact.

Useful? React with 👍 / 👎.

Comment thread src/session/index.ts Outdated
return rdns ? `${address}:${rdns}` : address;
const parts = [address];
if (rdns) parts.push(rdns);
if (userId) parts.push(userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Encode user IDs before storing dedup keys

With the new userId component included raw in the identified-wallet key, any app that uses an external userId containing a comma breaks session dedup: markWalletIdentified writes the key into a comma-joined cookie, and isWalletIdentified later splits on commas, so the exact key is never found and the same identify event re-emits on every call. Encode/escape the key components or store the list as JSON before adding arbitrary user IDs.

Useful? React with 👍 / 👎.

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed0006a3fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts Outdated
Comment on lines +393 to +396
await analytics.identify(
{ address: wallet.address, userId: user.id },
walletProperties
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unmatched active wallet during Privy sync

When formo.identify(user, { privy: true }) runs while the SDK's current address is a connected wallet that is not in user.linkedAccounts (for example a wagmi wallet before it has been linked in Privy), the convenience path passes that address as activeAddress but activeWallet is undefined. This loop still calls normal identify() for every linked wallet, and each call updates currentAddress, so the unlinked connected wallet is overwritten by the last linked wallet and later events are attributed to the wrong address. Preserve/restore the original current address when no linked wallet matches the active address, or avoid setting current address for clustering-only identifies.

Useful? React with 👍 / 👎.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dbd5d3c9e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts Outdated
Comment on lines +400 to +403
await analytics.identify(
{ address: wallet.address, userId: user.id },
walletProperties
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale chain id when activating Solana wallets

When a Privy user's active wallet is Solana after an EVM wallet/chain was already current, this call only passes address/userId into identify(). identify() updates currentAddress but leaves currentChainId untouched, so subsequent tracking, excludeChains, and the active-wallet cookie can pair the Solana address with the stale EVM chain id and either suppress or lose attribution. Since wallet.chainType is known here, clear or update the chain state when making a Solana wallet the active one.

Useful? React with 👍 / 👎.

Comment thread src/FormoAnalytics.ts Outdated
Comment on lines +776 to +778
if (!attributed && prevAddress && this.currentAddress !== prevAddress) {
this.currentAddress = prevAddress;
this.persistActiveWallet();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore user id with the unmatched wallet

When the connected wallet is not in user.linkedAccounts, the inner per-wallet identifies set currentUserId and the user-id cookie to the Privy DID before this fallback restores only currentAddress. The next track()/page() event is then sent with the unlinked connected wallet address plus the Privy DID, falsely associating that wallet with the Privy user; snapshot and restore the prior user id/cookie when falling back to the pre-sync address.

Useful? React with 👍 / 👎.

…tive state

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@yosriady

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c33237076

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts
Comment on lines +391 to +398
await identify(
{
address: wallet.address,
userId: user.id,
setActive: wallet === activeWallet,
},
walletProperties
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile chain state in the direct Privy helper

When consumers use the documented identifyPrivyUser(formo, user, { activeAddress }) entry point directly and select a Solana linked wallet after an EVM wallet is connected, these calls update currentAddress to the Solana address but leave the existing EVM currentChainId intact. The reconciliation exists only in the formo.identify(user, { privy: true }) dispatcher, despite the docs describing the two forms as equivalent, so subsequent events from the direct-helper flow can be emitted with a Solana address paired to an EVM chain ID.

Useful? React with 👍 / 👎.

Comment thread src/session/index.ts Outdated
Comment on lines +95 to +98
// still match (backward compatible).
const parts = [encodeURIComponent(address)];
if (rdns) parts.push(encodeURIComponent(rdns));
if (userId) parts.push(encodeURIComponent(userId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disambiguate user-ID-only session keys

When an application supplies a userId but no rdns whose value equals a provider RDNS (for example, io.metamask), this creates the same address:io.metamask key as an anonymous identify with rdns: "io.metamask". If the provider identify occurs first, the later user-ID identify is deduped and its identity-link event is never emitted, defeating the new userId-aware re-emission behavior. Encode the presence of each optional component (or always retain an empty RDNS field) so these distinct tuples cannot collide.

Useful? React with 👍 / 👎.

Comment thread src/FormoAnalytics.ts Outdated
Comment on lines +772 to +774
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile the Privy chain before emitting identifies

When tracking.excludeChains contains the currently connected EVM chain and this convenience form activates a linked Solana wallet, the helper emits its inner identify calls while currentChainId is still the excluded EVM value. Each inner trackEvent() therefore fails shouldTrack() and drops the wallet↔DID identify event; only after the loop does this line clear the stale chain ID. Reconcile the namespace before invoking the helper (or otherwise ensure those identify events are evaluated against the selected wallet) so the new Solana identity is not silently lost.

Useful? React with 👍 / 👎.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2581432b8e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts
Comment on lines +406 to +413
await identify(
{
address: wallet.address,
userId: user.id,
setActive: wallet === activeWallet,
},
walletProperties
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain dedup keys for every linked wallet

When a Privy user has more than the session store's 20-entry limit of linked wallets, this loop marks each wallet in sequence and evicts earlier (wallet, DID) keys. A later effect run then re-emits at least the evicted identities (and re-emits every wallet on each run once the set exceeds twice the limit), despite the documented per-session deduplication. Size the session identity store for the expanded linked-wallet set or avoid evicting keys generated by this synchronization.

Useful? React with 👍 / 👎.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 594dc71de8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts
Comment on lines +373 to +376
const activeWallet = resolveActiveWallet(
wallets,
options.activeAddress,
user.wallet?.address

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the connected wallet in the direct Privy helper

When callers use the documented direct identifyPrivyUser(formo, user) form without activeAddress after Formo has already connected an external wallet, this passes undefined and resolveActiveWallet promotes user.wallet, overwriting attribution with (for example) an embedded wallet. The flag wrapper now passes this.currentAddress before reaching this helper, but the direct helper is documented as equivalent; preserve the existing Formo address here as well, or require callers to supply it.

Useful? React with 👍 / 👎.

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1aab64170c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/FormoAnalytics.ts
Comment on lines +887 to +891
if (setActive !== false) {
this.currentAddress = validAddress;
this.persistActiveWallet();
if (userId) {
this.currentUserId = userId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the DID on non-active Privy identify events

For every linked wallet other than the resolved active one, setActive:false leaves currentUserId unchanged. trackEvent() passes that stale value into EventFactory.create(), which unconditionally overwrites the identify payload's user_id with its supplied current user ID (src/event/EventFactory.ts:804). Thus a fresh Privy sync sends user_id: null for all non-active wallets (or, worse, a previous user's ID), so only the active wallet is clustered under the Privy DID and the feature can corrupt existing identity associations. Pass the identify call's userId through to event creation without mutating persistent active state.

Useful? React with 👍 / 👎.

@yosriady

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1aab64170c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/privy/utils.ts Outdated
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve chain state when Privy identification is suppressed

When this helper is called on an excluded host/path (or after opt-out) with a linked wallet in the opposite namespace, this reconciliation runs before the inner identify() calls reach their suppression guard. It clears currentChainId even though none of the identifies may update the active wallet. In a SPA that then returns to an allowed route, the old EVM currentAddress remains but its excluded chain ID is gone, so shouldTrack() can no longer apply tracking.excludeChains and subsequent events for that wallet are collected. Guard reconciliation with the same suppression condition, or defer it until an active identify is actually accepted.

Useful? React with 👍 / 👎.

Comment thread src/FormoAnalytics.ts
Comment on lines +887 to +898
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 } : {}),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass the Privy DID through clustering events

For every non-active linked wallet, setActive:false intentionally leaves currentUserId unchanged here, but trackEvent() passes that state to EventManager, and EventFactory.create() overwrites an identify event's payload userId with this passed state. Thus a normal multi-wallet sync emits only the active wallet under the Privy DID; the other wallet identifies have user_id: null (or, worse, a previous user's ID), so the advertised server-side wallet clustering fails or is misattributed. Preserve the payload DID when enqueueing these identify events rather than relying on active SDK state.

Useful? React with 👍 / 👎.

claude added 2 commits July 23, 2026 08:34
…n suppressed

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVjnFuV1Bod4Vev9JYt2wq
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

yosriady and others added 2 commits August 4, 2026 14:39
…ware

Closes gaps found while wiring the Privy example's account-linking UI.

Parsing (src/privy):
- Extract cross_app wallets. A cross_app account (e.g. Abstract Global
  Wallet) has no top-level address — its wallets live in embeddedWallets/
  smartWallets, so they were dropped entirely and never clustered.
- Parse phone, twitch, and custom_auth's customUserId, none of which were
  reaching the identify payload.
- Deduplicate wallets by address (case-insensitive for EVM, exact for
  Solana), preferring a real wallet entry over a cross_app placeholder
  regardless of the order Privy lists accounts in.

Dedup (src/session, src/FormoAnalytics):
- Fold a fingerprint of the properties into the identify dedup key. Linking
  a social account leaves the wallets and the DID untouched, so every
  already-identified wallet used to dedupe and the new property never
  reached Formo until the session expired.
- Writing a key supersedes that (address, rdns, userId) identity's previous
  entry instead of appending, so dedup means "same as this wallet's last
  identify". A profile that reverts (link then unlink) re-emits, and the
  cookie stays at one entry per wallet-user rather than growing per change.
- The fingerprint is total: BigInt, functions, symbols, invalid Dates,
  NaN/Infinity, Map/Set and cycles canonicalize rather than throw; toJSON()
  is honored so wire-distinct values don't collide; undefined stays distinct
  from null; and a throwing getter degrades to pre-fingerprint dedup instead
  of losing the identify. This is about dedup only — BigInt/circular values
  still fail at the queue's native JSON.stringify, a pre-existing SDK-wide
  limitation that affects track() equally.
- Auto-identify's pre-check now passes properties so it uses the same key as
  the identify it guards.

Chain reconciliation:
- Infer the EVM namespace from a 0x address when Privy omits chainType
  (smart_wallet and cross_app entries carry no chainType), and treat only
  "solana"/"ethereum" as known so an unrecognized namespace no longer
  clears a valid chain id.

Account-set summary properties were considered and deliberately left out:
they're derivable server-side, and being shared across wallets they'd make
every link/unlink re-emit every wallet.

739 tests passing; lint, tsc, and full build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merge from main left literal duplicate entries for `picomatch@4.0.5`
and `fdir@6.5.0(picomatch@4.0.5)`, so every job that installs dependencies
failed immediately with:

  ERR_PNPM_BROKEN_LOCKFILE ... is broken: duplicated mapping key (1609:3)

This blocked build and audit, and cascaded into lint and test being
skipped. Regenerated by pnpm; the diff is purely the removal of the ten
duplicated lines, with no resolution or version changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc93e9813f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/session/index.ts Outdated
Comment on lines +442 to +445
let newValue = identifiedWallets.join(",");
while (
identifiedWallets.length > 1 &&
newValue.length > MAX_IDENTIFIED_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Budget the encoded cookie value

When the dedup store contains user IDs with non-ASCII or reserved characters, this length check underestimates the value written to the browser: each component is already percent-encoded, and CookieStorage.set() encodes the complete value again at src/storage/built-in/cookie.ts:42. A newValue below 3,500 characters can therefore exceed the browser's roughly 4 KB cookie limit, causing the write to be rejected and subsequent identify calls to re-emit because their keys were never persisted. Apply the limit to the final encoded cookie size, including the cookie name/overhead, rather than newValue.length.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/session/index.ts">

<violation number="1" location="src/session/index.ts:228">
P2: The identified-wallet cookie is not actually bounded to 3500 bytes: the check measures the unencoded string and never handles one oversized entry, while `CookieStorage.set()` percent-encodes the value. Long or Unicode external IDs can therefore overflow the cookie and make the same wallet re-identify repeatedly; sizing the encoded value and defining an oversized-key strategy would keep dedup reliable.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread src/privy/utils.ts
Comment thread test/privy/identifyPrivyUser.integration.spec.ts Outdated
Comment thread src/session/index.ts Outdated
Comment thread src/session/index.ts Outdated
* 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;

@cubic-dev-ai cubic-dev-ai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The identified-wallet cookie is not actually bounded to 3500 bytes: the check measures the unencoded string and never handles one oversized entry, while CookieStorage.set() percent-encodes the value. Long or Unicode external IDs can therefore overflow the cookie and make the same wallet re-identify repeatedly; sizing the encoded value and defining an oversized-key strategy would keep dedup reliable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/session/index.ts, line 228:

<comment>The identified-wallet cookie is not actually bounded to 3500 bytes: the check measures the unencoded string and never handles one oversized entry, while `CookieStorage.set()` percent-encodes the value. Long or Unicode external IDs can therefore overflow the cookie and make the same wallet re-identify repeatedly; sizing the encoded value and defining an oversized-key strategy would keep dedup reliable.</comment>

<file context>
@@ -54,23 +209,130 @@ export interface IFormoAnalyticsSession {
+ * 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 {
</file context>
Fix with cubic

Comment thread docs/PRIVY_INTEGRATION.md Outdated
Comment thread docs/PRIVY_INTEGRATION.md Outdated
Comment thread docs/PRIVY_INTEGRATION.md
Comment thread docs/PRIVY_INTEGRATION.md Outdated
Cookie budget (src/session):
- Measure the eviction budget on the ENCODED value. Key components are
  percent-encoded when built and CookieStorage encodes the joined value
  again, so `%3A` becomes `%253A` and each `,` becomes `%2C`. The raw-length
  check understated what is written: 37 realistic DID-bearing keys measure
  3500 raw but 3956 encoded, which with the cookie name and attributes
  clears the ~4KB limit. The browser then rejects the write, nothing
  persists, and every identify re-emits for the rest of the session — the
  exact failure the store exists to prevent.

- Widen the properties fingerprint from one 32-bit FNV lane to two. A single
  lane collides readily ({"x":"xefn1fnkq0"} and {"x":"filot3n704"} both hash
  to 1mgjpo5), and a collision silently suppresses a changed profile for the
  rest of the session.

- Mirror JSON.stringify's value semantics so the fingerprint tracks what is
  actually sent: undefined/function/symbol properties are omitted (and
  become null as array elements), NaN/Infinity become null, Map/Set send as
  {}. Previously these fingerprinted differently from their wire form, so a
  byte-identical payload could emit a second identify. null stays distinct
  from omitted, and BigInt/cycles keep markers since JSON cannot represent
  them at all.

Privy user parsing (src/privy):
- Normalize `createdAt`. PrivyUser is a structural interface: the React SDK
  supplies a Date, but a user from the REST API or a JSON round-trip carries
  an ISO string or epoch number, and `.getTime()` on those throws. Because
  identify()'s outer catch swallows it, the whole one-liner would silently
  emit nothing. Accepts Date/string/number, scales REST-style seconds, and
  yields undefined rather than NaN for anything unparseable.

Docs (docs/PRIVY_INTEGRATION.md):
- Document the `currentAddress` step in the attribution order, which was
  omitted — it is checked before `user.wallet` and matched strictly.
- Correct `identifyPrivyUser`'s return type; it resolves to the active
  wallet, not void.
- Warn against looping public identify() over parsePrivyProperties' wallets:
  public identify always promotes, so the last wallet takes attribution.
- Show a stable effect dependency, since dedup makes re-running safe but not
  free, and drop the React-only usePrivy() call from the framework-agnostic
  example.

Tests:
- Restore globalThis in the integration spec's teardown instead of deleting
  it, which unbound it process-wide for every later test file.

744 tests passing; lint, tsc, and full build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c046de5b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/session/index.ts
Comment on lines +100 to +102
return `[${value
.map((item) => stableStringify(item, seen) ?? "null")
.join(",")}]`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve sparse array holes in property fingerprints

When identify properties contain a sparse array, Array.prototype.map skips its holes, so a one-element sparse array fingerprints as [] even though JSON.stringify sends it as [null]. Consequently, changing a property from { tags: [] } to { tags: new Array(1) } is incorrectly deduplicated and the updated identify payload is never emitted during the session; iterate by array length so holes receive the same null representation as JSON serialization.

Useful? React with 👍 / 👎.

Comment thread src/session/index.ts
Comment on lines +493 to +495
while (
identifiedWallets.length > 1 &&
encodedCookieLength(newValue) > MAX_IDENTIFIED_ENCODED_BYTES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle a single dedup key that exceeds the cookie budget

When a caller supplies a long or heavily percent-encoded external userId whose generated key alone exceeds the encoded cookie budget, the new size loop cannot run because identifiedWallets.length > 1 is false. The oversized value is still written and can be rejected by the browser, leaving no dedup marker and causing every identical identify() call to emit again; hash or otherwise bound arbitrary key components, or explicitly handle an oversized singleton.

Useful? React with 👍 / 👎.

Comment thread src/privy/utils.ts
Comment on lines +550 to +554
await identify(
{
address: wallet.address,
userId: user.id,
setActive: wallet === activeWallet,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent stale Privy syncs from reclaiming active identity

When two Privy syncs overlap, as the documented un-awaited React effect can during an account switch or profile refresh, an older call can be suspended while identifying a non-active wallet, the newer call can promote the new user's wallet, and then the older call can resume and reach its active wallet here. That late promotion overwrites currentAddress and currentUserId with the previous user, so subsequent events are misattributed; serialize syncs, cancel stale generations, or perform the active promotion before the loop's first asynchronous yield.

Useful? React with 👍 / 👎.

Comment thread src/privy/utils.ts
Comment on lines +371 to +372
if (!wallet.fromCrossApp && deduped[existingIndex].fromCrossApp) {
deduped[existingIndex] = wallet;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the richest duplicate wallet entry

When the same address appears first as a smart_wallet with little metadata and later as a regular wallet carrying walletClientType, chainType, or the correct embedded classification, this condition retains the first entry because replacement is limited to cross-app placeholders. The emitted identify then loses the richer metadata, and resolveActiveWallet() can make a different fallback choice based on the incorrect isEmbedded value; compare the available metadata for every duplicate rather than only checking fromCrossApp.

Useful? React with 👍 / 👎.

Comment thread src/privy/utils.ts
Comment on lines +560 to +562
return activeWallet
? { address: activeWallet.address, chainType: activeWallet.chainType }
: undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject unsupported wallets before selecting the active one

When an unsupported or malformed linked wallet is selected as active—for example a chainType: "bitcoin" entry permitted by these structural types—FormoAnalytics.identify() rejects its address, while every valid linked wallet was invoked with setActive: false. Nevertheless, this return reports the rejected wallet as owning attribution even though the prior identity remains active; validate or filter wallets before active resolution and only return a wallet that was successfully promoted.

Useful? React with 👍 / 👎.

Comment thread src/FormoAnalytics.ts
Comment on lines +1012 to +1014
if (walletIsSolana !== currentIsSolana) {
this.currentChainId = undefined;
this.persistActiveWallet();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the namespace when clearing a stale chain ID

When a Solana Privy wallet replaces an EVM wallet, clearing the chain ID here causes the subsequent active-wallet snapshot to contain the Solana address with no namespace information. On reload, loadActiveWallet() classifies a snapshot with no chain ID as EVM (isSolanaChainId(undefined) is false), stores the Solana address in _chainState.evm, and a later EVM disconnect or state synchronization can clear or replace the active Solana attribution; persist the resolved namespace separately when the exact chain ID is unknown.

Useful? React with 👍 / 👎.

Comment thread docs/PRIVY_INTEGRATION.md
Comment on lines +65 to +68
> // Re-runs on login, link, and unlink — not on every render.
> const identityKey = user
> ? `${user.id}:${user.linkedAccounts?.length ?? 0}`
> : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include profile contents in the recommended effect key

When Privy updates profile data without changing the number of linked accounts—for example an OAuth username/email refresh or a same-count account replacement—this recommended identityKey remains unchanged, so the effect does not call identify() and the new properties or wallet never reach Formo. This contradicts the preceding promise that the dependency changes with the profile; derive the key from the relevant linked-account identities and profile fields, or retain user as the dependency.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/session/index.ts">

<violation number="1" location="src/session/index.ts:100">
P2: Sparse arrays can be fingerprinted differently from their actual JSON payload, so dedup may emit a duplicate identify for values that serialize identically. The new array branch uses `.map(...)`, which skips holes; iterating by index (0..length-1) keeps JSON’s `null` behavior for missing elements.</violation>
</file>

<file name="docs/PRIVY_INTEGRATION.md">

<violation number="1" location="docs/PRIVY_INTEGRATION.md:67">
P2: The recommended `identityKey = `${user.id}:${user.linkedAccounts?.length ?? 0}`` only changes when the number of linked accounts changes. A profile update that doesn't change the account count (e.g. an OAuth email/username refresh) leaves this key unchanged, so the effect won't re-run and the updated properties never reach Formo — contradicting the surrounding text's claim that the key tracks profile changes. Consider deriving the key from relevant profile fields as well, not just account count.</violation>

<violation number="2" location="docs/PRIVY_INTEGRATION.md:74">
P2: Changing the connected wallet can leave attribution stale when this example is extended with `activeAddress`: the value is not included in the effect dependencies, and `wallets` is not defined in the shown snippet. The active address should be obtained explicitly (for example via `useWallets()`) and included as a scalar dependency alongside `identityKey`.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/session/index.ts
Comment on lines +100 to +102
return `[${value
.map((item) => stableStringify(item, seen) ?? "null")
.join(",")}]`;

@cubic-dev-ai cubic-dev-ai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Sparse arrays can be fingerprinted differently from their actual JSON payload, so dedup may emit a duplicate identify for values that serialize identically. The new array branch uses .map(...), which skips holes; iterating by index (0..length-1) keeps JSON’s null behavior for missing elements.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/session/index.ts, line 100:

<comment>Sparse arrays can be fingerprinted differently from their actual JSON payload, so dedup may emit a duplicate identify for values that serialize identically. The new array branch uses `.map(...)`, which skips holes; iterating by index (0..length-1) keeps JSON’s `null` behavior for missing elements.</comment>

<file context>
@@ -33,69 +33,84 @@ import type { IFormoEventProperties } from "../types/events";
-        .map((item) => stableStringify(item, seen))
-        .sort()
-        .join(",")}}`;
+      return `[${value
+        .map((item) => stableStringify(item, seen) ?? "null")
+        .join(",")}]`;
</file context>
Suggested change
return `[${value
.map((item) => stableStringify(item, seen) ?? "null")
.join(",")}]`;
return `[${Array.from({ length: value.length }, (_, index) =>
stableStringify(value[index], seen) ?? "null"
).join(",")}]`;
Fix with cubic

Comment thread docs/PRIVY_INTEGRATION.md
> if (formo && authenticated && user) {
> formo.identify(user, { privy: true });
> }
> }, [formo, authenticated, identityKey]);

@cubic-dev-ai cubic-dev-ai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changing the connected wallet can leave attribution stale when this example is extended with activeAddress: the value is not included in the effect dependencies, and wallets is not defined in the shown snippet. The active address should be obtained explicitly (for example via useWallets()) and included as a scalar dependency alongside identityKey.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/PRIVY_INTEGRATION.md, line 74:

<comment>Changing the connected wallet can leave attribution stale when this example is extended with `activeAddress`: the value is not included in the effect dependencies, and `wallets` is not defined in the shown snippet. The active address should be obtained explicitly (for example via `useWallets()`) and included as a scalar dependency alongside `identityKey`.</comment>

<file context>
@@ -54,9 +54,29 @@ That single `identify(user, { privy: true })` call identifies **every** wallet
+>   if (formo && authenticated && user) {
+>     formo.identify(user, { privy: true });
+>   }
+> }, [formo, authenticated, identityKey]);
+> ```
+>
</file context>
Fix with cubic

Comment thread docs/PRIVY_INTEGRATION.md
> ```ts
> // Re-runs on login, link, and unlink — not on every render.
> const identityKey = user
> ? `${user.id}:${user.linkedAccounts?.length ?? 0}`

@cubic-dev-ai cubic-dev-ai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The recommended identityKey = ${user.id}:${user.linkedAccounts?.length ?? 0}`` only changes when the number of linked accounts changes. A profile update that doesn't change the account count (e.g. an OAuth email/username refresh) leaves this key unchanged, so the effect won't re-run and the updated properties never reach Formo — contradicting the surrounding text's claim that the key tracks profile changes. Consider deriving the key from relevant profile fields as well, not just account count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/PRIVY_INTEGRATION.md, line 67:

<comment>The recommended `identityKey = `${user.id}:${user.linkedAccounts?.length ?? 0}`` only changes when the number of linked accounts changes. A profile update that doesn't change the account count (e.g. an OAuth email/username refresh) leaves this key unchanged, so the effect won't re-run and the updated properties never reach Formo — contradicting the surrounding text's claim that the key tracks profile changes. Consider deriving the key from relevant profile fields as well, not just account count.</comment>

<file context>
@@ -54,9 +54,29 @@ That single `identify(user, { privy: true })` call identifies **every** wallet
+> ```ts
+> // Re-runs on login, link, and unlink — not on every render.
+> const identityKey = user
+>   ? `${user.id}:${user.linkedAccounts?.length ?? 0}`
+>   : null;
+>
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants