From d65589e0b7824db2fbf3045e8810b5fa0b823044 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 10:55:24 +0700 Subject: [PATCH 01/17] fix: gate identify on the tracking policy, and make wallet markers expire and dedup like the web SDK - identify() now checks shouldTrack() before it writes any state, the way detect() does. A marker written on an excluded chain no longer silences a later identify on an allowed chain. - EventQueue.clear() resets the first-event flag, so the first event after opt-in ships at once instead of waiting a full flush interval. - The detect and identify marker sets each carry their own timestamp (wallet_detected_at, wallet_identified_at) and expire a day from their own last write. A future or non-finite timestamp is clamped to now. Legacy sets without a timestamp are stamped on load. - The identify dedup key folds in the user id and a fingerprint of the properties, and marking replaces the wallet's earlier entries, so a changed user id or profile is sent again. - Each marker set is capped at 20 entries, dropping the oldest. - The track dedup fingerprints use a key-sorted serialization, so property order no longer splits one event in two. Message ids and the wire payload are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/FormoAnalytics.ts | 13 +- src/__tests__/FormoAnalytics.test.ts | 72 ++++++++ src/__tests__/eventManager.test.ts | 38 ++++ src/__tests__/eventQueue.test.ts | 50 ++++- src/__tests__/hash.test.ts | 38 +++- src/__tests__/session.test.ts | 234 ++++++++++++++++++++++-- src/constants/storage.ts | 10 +- src/lib/event/EventManager.ts | 7 +- src/lib/event/EventQueue.ts | 10 +- src/lib/session/index.ts | 263 +++++++++++++++++++-------- src/utils/hash.ts | 51 ++++++ 11 files changed, 678 insertions(+), 108 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 03f1ba9..4983ee4 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -700,7 +700,14 @@ export class FormoAnalytics implements IFormoAnalytics { try { const { userId, address, providerName, rdns } = params; logger.info("Identify", address, userId, providerName, rdns); - if (this.hasOptedOutTracking()) return; + // The full policy, not consent alone: identify persists the user id + // and marks the wallet before trackEvent applies the chain gate, and + // a marker written on an excluded chain would silence a later + // identify on an allowed one. + if (!this.shouldTrack()) { + logger.info("identify() skipped: tracking is suppressed for this wallet or chain"); + return; + } let validAddress: Address | undefined = undefined; if (address) { @@ -725,7 +732,7 @@ export class FormoAnalytics implements IFormoAnalytics { // Check for duplicate identify const isAlreadyIdentified = validAddress - ? this.session.isWalletIdentified(validAddress, rdns || "") + ? this.session.isWalletIdentified(validAddress, rdns || "", userId, properties) : false; if (isAlreadyIdentified) { @@ -737,7 +744,7 @@ export class FormoAnalytics implements IFormoAnalytics { // Mark as identified if (validAddress) { - this.session.markWalletIdentified(validAddress, rdns || ""); + this.session.markWalletIdentified(validAddress, rdns || "", userId, properties); } await this.trackEvent( diff --git a/src/__tests__/FormoAnalytics.test.ts b/src/__tests__/FormoAnalytics.test.ts index 1a3dc26..9886999 100644 --- a/src/__tests__/FormoAnalytics.test.ts +++ b/src/__tests__/FormoAnalytics.test.ts @@ -815,6 +815,78 @@ describe('FormoAnalytics', () => { }); }); + describe('identify() tracking gate', () => { + const address = '0x742d35cc6634c0532925a3b844bc9e7595f3f6d2'; + + it('writes nothing on an excluded chain, and identifies on an allowed one', async () => { + analytics.options.tracking = { excludeChains: [1] }; + analytics.currentChainId = 1; + + await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' }); + + expect(analytics.currentAddress).toBeUndefined(); + expect(analytics.currentUserId).toBeUndefined(); + expect(mockStorageInstance.set).not.toHaveBeenCalledWith('user_id', expect.anything()); + expect(mockSession.markWalletIdentified).not.toHaveBeenCalled(); + expect(mockEventManager.addEvent).not.toHaveBeenCalled(); + + analytics.currentChainId = 137; + await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' }); + + expect(mockStorageInstance.set).toHaveBeenCalledWith('user_id', 'user-1'); + expect(mockSession.markWalletIdentified).toHaveBeenCalledWith( + expect.any(String), + 'io.metamask', + 'user-1', + undefined + ); + expect(mockEventManager.addEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'identify', userId: 'user-1' }), + expect.any(String), + 'user-1' + ); + }); + + it('writes nothing when tracking is off', async () => { + analytics.options.tracking = false; + + await analytics.identify({ address, userId: 'user-1' }); + + expect(analytics.currentAddress).toBeUndefined(); + expect(mockStorageInstance.set).not.toHaveBeenCalledWith('user_id', expect.anything()); + expect(mockSession.markWalletIdentified).not.toHaveBeenCalled(); + expect(mockEventManager.addEvent).not.toHaveBeenCalled(); + }); + + it('checks and marks the wallet with the user id and the properties', async () => { + const properties = { plan: 'pro' }; + + await analytics.identify({ address, userId: 'user-1', rdns: 'io.metamask' }, properties); + + expect(mockSession.isWalletIdentified).toHaveBeenCalledWith( + expect.any(String), + 'io.metamask', + 'user-1', + properties + ); + expect(mockSession.markWalletIdentified).toHaveBeenCalledWith( + expect.any(String), + 'io.metamask', + 'user-1', + properties + ); + }); + + it('skips the event when the session already holds this identity', async () => { + mockSession.isWalletIdentified.mockReturnValue(true); + + await analytics.identify({ address, userId: 'user-1' }); + + expect(mockSession.markWalletIdentified).not.toHaveBeenCalled(); + expect(mockEventManager.addEvent).not.toHaveBeenCalled(); + }); + }); + describe('optOutTracking()', () => { it('should clear the anonymous id and the stored attribution as well', () => { analytics.optOutTracking(); diff --git a/src/__tests__/eventManager.test.ts b/src/__tests__/eventManager.test.ts index c930bd4..5248415 100644 --- a/src/__tests__/eventManager.test.ts +++ b/src/__tests__/eventManager.test.ts @@ -95,6 +95,44 @@ describe("EventManager custom-event identity", () => { expect(b![3].dedupKey).toBe(a![3].dedupKey); }); + it("fingerprints the same properties the same whatever their key order", async () => { + const { queue, manager } = makeManager(); + + await manager.addEvent({ type: "track", event: "Purchase", properties: { currency: "USD", amount: 10 } }); + await manager.addEvent({ type: "track", event: "Purchase", properties: { amount: 10, currency: "USD" } }); + + const [a, b] = enqueueCalls(queue); + expect(b![3].dedupKey).toBe(a![3].dedupKey); + }); + + it("sorts keys in nested objects too", async () => { + const { queue, manager } = makeManager(); + + await manager.addEvent({ + type: "track", + event: "Purchase", + properties: { item: { sku: "x", qty: 1 }, currency: "USD" }, + }); + await manager.addEvent({ + type: "track", + event: "Purchase", + properties: { currency: "USD", item: { qty: 1, sku: "x" } }, + }); + + const [a, b] = enqueueCalls(queue); + expect(b![3].dedupKey).toBe(a![3].dedupKey); + }); + + it("keeps array order in the fingerprint", async () => { + const { queue, manager } = makeManager(); + + await manager.addEvent({ type: "track", event: "Purchase", properties: { tags: ["a", "b"] } }); + await manager.addEvent({ type: "track", event: "Purchase", properties: { tags: ["b", "a"] } }); + + const [a, b] = enqueueCalls(queue); + expect(b![3].dedupKey).not.toBe(a![3].dedupKey); + }); + it("keeps caller-supplied context in the fingerprint", async () => { const { queue, manager } = makeManager(); const call = { type: "track" as const, event: "Order Placed", properties: { market: "ZEC" } }; diff --git a/src/__tests__/eventQueue.test.ts b/src/__tests__/eventQueue.test.ts index 1bad704..9770c74 100644 --- a/src/__tests__/eventQueue.test.ts +++ b/src/__tests__/eventQueue.test.ts @@ -514,7 +514,9 @@ describe("EventQueue", () => { expect(fetchMock).toHaveBeenCalledTimes(1); // Opt out, then the same event is produced again and re-enqueued. + // Only sends after the opt-out count from here on. queue.clear(); + fetchMock.mockClear(); await queue.enqueue(makeEvent(1)); await settle(); @@ -524,8 +526,6 @@ describe("EventQueue", () => { await settle(); // A third identical enqueue must still be recognised as a duplicate. - fetchMock.mockClear(); - fetchMock.mockResolvedValue({ ok: true, status: 200 }); await queue.enqueue(makeEvent(1)); await queue.flush(); await settle(); @@ -539,6 +539,26 @@ describe("EventQueue", () => { }); }); + describe("first event after clear()", () => { + it("ships at once instead of waiting for the batch interval", async () => { + const queue = makeQueue({ flushAt: 20 }); + + await queue.enqueue(makeEvent(1)); + await settle(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Opt out, then opt in: the next event starts a fresh lifecycle. + queue.clear(); + await queue.enqueue(makeEvent(2)); + await settle(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(sentEvents().map((e) => e.event)).toEqual(["event-1", "event-2"]); + + await queue.cleanup(); + }); + }); + describe("enqueue racing clear()", () => { it("drops an event whose hashing was still pending when the user opted out", async () => { const queue = makeQueue(); @@ -1161,6 +1181,32 @@ describe("EventQueue dedup window", () => { } }); + it("collapses two events whose properties differ only in key order", async () => { + const queue = makeQueue(); + + await queue.enqueue(event({ properties: { currency: "USD", amount: 10 } })); + await settle(); + await queue.enqueue(event({ properties: { amount: 10, currency: "USD" } })); + await queue.flush(); + await settle(); + + expect(sent()).toHaveLength(1); + await queue.cleanup(); + }); + + it("keeps two events whose array order differs", async () => { + const queue = makeQueue(); + + await queue.enqueue(event({ properties: { tags: ["a", "b"] } })); + await settle(); + await queue.enqueue(event({ properties: { tags: ["b", "a"] } })); + await queue.flush(); + await settle(); + + expect(sent()).toHaveLength(2); + await queue.cleanup(); + }); + it("gives separate unkeyed custom events distinct random ids", async () => { const queue = makeQueue(); diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 825d7e2..89cc05d 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -1,4 +1,4 @@ -import { hash, generateUUID } from '../utils/hash'; +import { hash, generateUUID, stableStringify } from '../utils/hash'; describe('hash utilities', () => { describe('hash()', () => { @@ -111,4 +111,40 @@ describe('hash utilities', () => { }); }); }); + + describe('stableStringify()', () => { + it('sorts object keys recursively', () => { + expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe( + '{"a":{"c":3,"d":2},"b":1}' + ); + }); + + it('keeps array order', () => { + expect(stableStringify([2, 1])).toBe('[2,1]'); + expect(stableStringify([2, 1])).not.toBe(stableStringify([1, 2])); + }); + + it('follows JSON.stringify for primitives and omitted values', () => { + expect(stableStringify({ a: undefined, b: () => 1, c: null, d: NaN })).toBe( + '{"c":null,"d":null}' + ); + expect(stableStringify([undefined])).toBe('[null]'); + expect(stableStringify('x')).toBe('"x"'); + expect(stableStringify(undefined)).toBeUndefined(); + const date = new Date('2026-01-01T00:00:00Z'); + expect(stableStringify({ at: date })).toBe(JSON.stringify({ at: date })); + }); + + it('throws where JSON.stringify throws', () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => stableStringify(cyclic)).toThrow(TypeError); + expect(() => stableStringify({ n: BigInt(1) })).toThrow(TypeError); + }); + + it('serializes a repeated sibling object twice, not as a cycle', () => { + const shared = { a: 1 }; + expect(stableStringify({ x: shared, y: shared })).toBe('{"x":{"a":1},"y":{"a":1}}'); + }); + }); }); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 7598fbf..fd7b71d 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -51,6 +51,18 @@ describe('FormoAnalyticsSession', () => { session.markWalletDetected('io.metamask'); expect(mockStorage.set).toHaveBeenCalled(); }); + + it('caps the set at 20 entries, dropping the oldest', () => { + for (let i = 0; i < 21; i++) { + session.markWalletDetected(`wallet.${i}`); + } + + expect(session.isWalletDetected('wallet.0')).toBe(false); + expect(session.isWalletDetected('wallet.1')).toBe(true); + expect(session.isWalletDetected('wallet.20')).toBe(true); + const stored = JSON.parse(mockStorage.set.mock.calls.at(-1)![1]) as string[]; + expect(stored).toHaveLength(20); + }); }); describe('wallet identification', () => { @@ -87,6 +99,58 @@ describe('FormoAnalyticsSession', () => { session.markWalletIdentified(address, rdns); expect(mockStorage.set).toHaveBeenCalled(); }); + + it('identifies again when the user id changes', () => { + session.markWalletIdentified(address, rdns, 'user-a'); + expect(session.isWalletIdentified(address, rdns, 'user-a')).toBe(true); + expect(session.isWalletIdentified(address, rdns, 'user-b')).toBe(false); + }); + + it('dedupes the same user id', () => { + session.markWalletIdentified(address, rdns, 'user-a'); + session.markWalletIdentified(address, rdns, 'user-a'); + expect(session.isWalletIdentified(address, rdns, 'user-a')).toBe(true); + expect(mockStorage.set).toHaveBeenCalledTimes(2); // one write: set and timestamp + }); + + it('identifies again when the properties change', () => { + session.markWalletIdentified(address, rdns, 'user-a', { plan: 'free' }); + expect(session.isWalletIdentified(address, rdns, 'user-a', { plan: 'free' })).toBe(true); + expect(session.isWalletIdentified(address, rdns, 'user-a', { plan: 'pro' })).toBe(false); + }); + + it('dedupes properties whatever their key order', () => { + session.markWalletIdentified(address, rdns, 'user-a', { plan: 'free', tier: 1 }); + expect(session.isWalletIdentified(address, rdns, 'user-a', { tier: 1, plan: 'free' })).toBe(true); + }); + + it('treats an empty properties object like none', () => { + session.markWalletIdentified(address, rdns); + expect(session.isWalletIdentified(address, rdns, undefined, {})).toBe(true); + }); + + it('keeps only the latest state of a wallet', () => { + session.markWalletIdentified(address, rdns, 'user-a'); + session.markWalletIdentified(address, rdns, 'user-b'); + + expect(session.isWalletIdentified(address, rdns, 'user-b')).toBe(true); + // The wallet's last identify carried user-b, so user-a is a change again. + expect(session.isWalletIdentified(address, rdns, 'user-a')).toBe(false); + const stored = JSON.parse(mockStorage.set.mock.calls.at(-1)![1]) as string[]; + expect(stored).toHaveLength(1); + }); + + it('caps the set at 20 entries, dropping the oldest', () => { + for (let i = 0; i < 21; i++) { + session.markWalletIdentified(`0x${String(i).padStart(40, '0')}`, rdns); + } + + expect(session.isWalletIdentified(`0x${'0'.repeat(40)}`, rdns)).toBe(false); + expect(session.isWalletIdentified(`0x${'1'.padStart(40, '0')}`, rdns)).toBe(true); + expect(session.isWalletIdentified(`0x${'20'.padStart(40, '0')}`, rdns)).toBe(true); + const stored = JSON.parse(mockStorage.set.mock.calls.at(-1)![1]) as string[]; + expect(stored).toHaveLength(20); + }); }); describe('clear()', () => { @@ -100,9 +164,13 @@ describe('FormoAnalyticsSession', () => { expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(false); }); - it('should remove from storage when clearing', () => { + it('should remove both sets and both timestamps from storage', () => { session.clear(); - expect(mockStorage.remove).toHaveBeenCalledTimes(3); + expect(mockStorage.remove).toHaveBeenCalledTimes(4); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_detected'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_detected_at'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_identified'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_identified_at'); }); }); @@ -128,13 +196,23 @@ describe('FormoAnalyticsSession', () => { it('stamps the first write and keeps markers within the day', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); session.markWalletDetected('io.metamask'); - expect(mockStorage.set).toHaveBeenCalledWith('wallet_marked_at', String(Date.now())); + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); jest.setSystemTime(new Date('2026-09-09T23:00:00Z')); expect(session.isWalletDetected('io.metamask')).toBe(true); }); - it('forgets every marker a day after the first write, in memory', () => { + it('does not renew the day for a marker that is already there', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + session.markWalletDetected('io.metamask'); + jest.setSystemTime(new Date('2026-09-09T12:00:00Z')); + session.markWalletDetected('io.metamask'); + + jest.setSystemTime(new Date('2026-09-10T06:00:00Z')); + expect(session.isWalletDetected('io.metamask')).toBe(false); + }); + + it('forgets every marker a day after the write, in memory', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); session.markWalletDetected('io.metamask'); session.markWalletIdentified('0x123', 'io.metamask'); @@ -143,13 +221,31 @@ describe('FormoAnalyticsSession', () => { expect(session.isWalletDetected('io.metamask')).toBe(false); expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(false); - expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_detected_at'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_identified_at'); + }); + + it('expires each set from its own last write', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + session.markWalletDetected('io.metamask'); + jest.setSystemTime(new Date('2026-09-09T20:00:00Z')); + session.markWalletIdentified('0x123', 'io.metamask'); + + // The detect day ends; the identify write did not renew it. + jest.setSystemTime(new Date('2026-09-10T01:00:00Z')); + expect(session.isWalletDetected('io.metamask')).toBe(false); + expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(true); + expect(mockStorage.remove).not.toHaveBeenCalledWith('wallet_identified'); + + // The identify day ends on its own clock. + jest.setSystemTime(new Date('2026-09-10T20:00:01Z')); + expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(false); }); it('drops stale markers found in storage on load', () => { const stale = String(Date.now() - DAY - 1000); mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_marked_at') return stale; + if (key === 'wallet_detected_at') return stale; if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); return null; }); @@ -160,25 +256,48 @@ describe('FormoAnalyticsSession', () => { expect(mockStorage.remove).toHaveBeenCalledWith('wallet_detected'); }); + it('drops only the stale set on load', () => { + const stale = String(Date.now() - DAY - 1000); + const recent = String(Date.now() - 1000); + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_detected_at') return stale; + if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); + if (key === 'wallet_identified_at') return recent; + if (key === 'wallet_identified') return JSON.stringify(['0x123:io.metamask::']); + return null; + }); + + const fresh = new FormoAnalyticsSession(); + + expect(fresh.isWalletDetected('io.metamask')).toBe(false); + expect(fresh.isWalletIdentified('0x123', 'io.metamask')).toBe(true); + }); + it('stamps markers written by a version without the timestamp, so they expire too', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); mockStorage.get.mockImplementation((key: string) => { if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - return null; // no wallet_marked_at: written before the expiry existed + if (key === 'wallet_identified') return JSON.stringify(['0x123:io.metamask']); + if (key === 'wallet_marked_at') return String(Date.now() - 1000); + return null; // no per-set timestamp: written before it existed }); const upgraded = new FormoAnalyticsSession(); - expect(mockStorage.set).toHaveBeenCalledWith('wallet_marked_at', String(Date.now())); + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); + expect(mockStorage.set).toHaveBeenCalledWith('wallet_identified_at', String(Date.now())); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); expect(upgraded.isWalletDetected('io.metamask')).toBe(true); + expect(upgraded.isWalletIdentified('0x123', 'io.metamask')).toBe(true); jest.setSystemTime(new Date('2026-09-10T00:00:01Z')); expect(upgraded.isWalletDetected('io.metamask')).toBe(false); + expect(upgraded.isWalletIdentified('0x123', 'io.metamask')).toBe(false); }); it('keeps markers found in storage that are still within the day', () => { const recent = String(Date.now() - 1000); mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_marked_at') return recent; + if (key === 'wallet_detected_at') return recent; if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); return null; }); @@ -187,9 +306,58 @@ describe('FormoAnalyticsSession', () => { expect(fresh.isWalletDetected('io.metamask')).toBe(true); }); + + it('clamps a future timestamp to now on load', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + // Written while the clock was set to 2027, then the clock was corrected. + const future = String(new Date('2027-01-01T00:00:00Z').getTime()); + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_detected_at') return future; + if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); + return null; + }); + + const fresh = new FormoAnalyticsSession(); + + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); + expect(fresh.isWalletDetected('io.metamask')).toBe(true); + jest.setSystemTime(new Date('2026-09-10T00:00:01Z')); + expect(fresh.isWalletDetected('io.metamask')).toBe(false); + }); + + it('clamps a timestamp the clock has moved behind, on check', () => { + jest.useFakeTimers().setSystemTime(new Date('2027-01-01T00:00:00Z')); + session.markWalletDetected('io.metamask'); + + // The clock is corrected while the app runs. + jest.setSystemTime(new Date('2026-09-09T00:00:00Z')); + expect(session.isWalletDetected('io.metamask')).toBe(true); + expect(mockStorage.set).toHaveBeenLastCalledWith('wallet_detected_at', String(Date.now())); + + jest.setSystemTime(new Date('2026-09-10T00:00:01Z')); + expect(session.isWalletDetected('io.metamask')).toBe(false); + }); + + it('treats a non-finite timestamp as missing and stamps now', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_detected_at') return 'not-a-number'; + if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); + return null; + }); + + const fresh = new FormoAnalyticsSession(); + + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); + expect(fresh.isWalletDetected('io.metamask')).toBe(true); + }); }); describe('clearIdentified()', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('forgets identified wallets but keeps detected ones', () => { session.markWalletDetected('io.metamask'); session.markWalletIdentified('0x123', 'io.metamask'); @@ -198,22 +366,23 @@ describe('FormoAnalyticsSession', () => { expect(session.isWalletDetected('io.metamask')).toBe(true); expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(false); - expect(mockStorage.remove).toHaveBeenCalledTimes(1); - expect(mockStorage.remove).not.toHaveBeenCalledWith('wallet_marked_at'); + expect(mockStorage.remove).toHaveBeenCalledTimes(2); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_identified'); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_identified_at'); }); - it('restarts the day when it removes the last marker', () => { + it('restarts the identify day, and leaves the detect day alone', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); - session.markWalletIdentified('0x123', 'io.metamask'); // identify without a detect + session.markWalletDetected('io.metamask'); + session.markWalletIdentified('0x123', 'io.metamask'); session.clearIdentified(); - expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); jest.setSystemTime(new Date('2026-09-09T23:00:00Z')); - session.markWalletDetected('io.metamask'); + session.markWalletIdentified('0x123', 'io.metamask'); jest.setSystemTime(new Date('2026-09-10T00:00:01Z')); - expect(session.isWalletDetected('io.metamask')).toBe(true); // a full day from its own write - jest.useRealTimers(); + expect(session.isWalletIdentified('0x123', 'io.metamask')).toBe(true); // a full day from its own write + expect(session.isWalletDetected('io.metamask')).toBe(false); // the detect day was not renewed }); }); @@ -234,7 +403,21 @@ describe('FormoAnalyticsSession', () => { it('should load identified wallets from storage', () => { mockStorage.get.mockImplementation((key: string) => { - if (key.includes('identified')) { + if (key === 'wallet_identified') { + return JSON.stringify(['0x123:io.metamask::', '0x456:io.metamask:user-a:']); + } + return null; + }); + + const loadedSession = new FormoAnalyticsSession(); + + expect(loadedSession.isWalletIdentified('0x123', 'io.metamask')).toBe(true); + expect(loadedSession.isWalletIdentified('0x456', 'io.metamask', 'user-a')).toBe(true); + }); + + it('still matches keys written before the user id joined them', () => { + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_identified') { return JSON.stringify(['0x123:io.metamask']); } return null; @@ -243,6 +426,21 @@ describe('FormoAnalyticsSession', () => { const loadedSession = new FormoAnalyticsSession(); expect(loadedSession.isWalletIdentified('0x123', 'io.metamask')).toBe(true); + expect(loadedSession.isWalletIdentified('0x123', 'io.metamask', 'user-a')).toBe(false); + }); + + it('keeps only the newest 20 entries of an oversized set', () => { + const many = Array.from({ length: 25 }, (_, i) => `wallet.${i}`); + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_detected') return JSON.stringify(many); + return null; + }); + + const loadedSession = new FormoAnalyticsSession(); + + expect(loadedSession.isWalletDetected('wallet.4')).toBe(false); + expect(loadedSession.isWalletDetected('wallet.5')).toBe(true); + expect(loadedSession.isWalletDetected('wallet.24')).toBe(true); }); it('should handle invalid JSON in storage gracefully', () => { diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 4958e61..8cae5bb 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -17,11 +17,15 @@ export const LOCAL_INSTALL_REFERRER_RESOLVED_KEY = "install_referrer_resolved"; export const SESSION_USER_ID_KEY = "user_id"; export const SESSION_TRAFFIC_SOURCE_KEY = "traffic_source"; export const SESSION_WALLET_DETECTED_KEY = "wallet_detected"; -/** When the wallet markers were last written; they expire a day later, as on web. */ -export const SESSION_WALLET_MARKED_AT_KEY = "wallet_marked_at"; +export const SESSION_WALLET_IDENTIFIED_KEY = "wallet_identified"; +/** + * When each marker set was last written. Each set expires a day after its + * own last write, as each of the web SDK's marker cookies does. + */ +export const SESSION_WALLET_DETECTED_AT_KEY = "wallet_detected_at"; +export const SESSION_WALLET_IDENTIFIED_AT_KEY = "wallet_identified_at"; /** Wallet markers live a day, the lifetime of the web SDK's marker cookie. */ export const WALLET_MARKER_TTL_MS = 24 * 60 * 60 * 1000; -export const SESSION_WALLET_IDENTIFIED_KEY = "wallet_identified"; // Consent keys export const CONSENT_OPT_OUT_KEY = "opt_out_tracking"; diff --git a/src/lib/event/EventManager.ts b/src/lib/event/EventManager.ts index f4ed638..c903223 100644 --- a/src/lib/event/EventManager.ts +++ b/src/lib/event/EventManager.ts @@ -3,7 +3,7 @@ import { logger } from "../logger"; import { EVENT_CREATION_CANCELLED, EventFactory } from "./EventFactory"; import { IEventFactory, IEventManager, IEventQueue } from "./types"; import { isBlockedAddress } from "../../utils/address"; -import { hash } from "../../utils/hash"; +import { hash, stableStringify } from "../../utils/hash"; /** * Event manager for React Native SDK @@ -41,14 +41,15 @@ class EventManager implements IEventManager { // must not fingerprint the event under values it did not carry. // Custom events are fingerprinted on what the caller passed, so SDK // context that changes between two identical calls does not split them. + // Keys are sorted so the order the caller wrote them in does not either. const dedupKey = event.type === "track" ? hash( - JSON.stringify({ + stableStringify({ event: _event, address: address ?? null, userId: userId ?? null, - }) + }) ?? "" ) : undefined; diff --git a/src/lib/event/EventQueue.ts b/src/lib/event/EventQueue.ts index 0b90fb8..0495fb6 100644 --- a/src/lib/event/EventQueue.ts +++ b/src/lib/event/EventQueue.ts @@ -7,7 +7,7 @@ import { millisecondsToSecond, isNetworkError, } from "../../utils"; -import { hash, generateUUID } from "../../utils/hash"; +import { hash, generateUUID, stableStringify } from "../../utils/hash"; import { toDateHourMinute } from "../../utils/timestamp"; import { logger } from "../logger"; import { EnqueueOptions, IEventQueue } from "./types"; @@ -261,7 +261,8 @@ export class EventQueue implements IEventQueue { /** * Fallback fingerprint: the event without its timestamp and without the * volatile generated context, so a double-fire across a minute boundary - * or a screen rotation still matches. Custom events pass a pre-enrichment + * or a screen rotation still matches. Keys are sorted so property order + * does not split one event in two. Custom events pass a pre-enrichment * fingerprint instead. */ private generateDedupKey(event: IFormoEvent): string { @@ -270,7 +271,7 @@ export class EventQueue implements IEventQueue { if (stableContext) { for (const field of VOLATILE_CONTEXT_FIELDS) delete stableContext[field]; } - return hash(JSON.stringify({ ...rest, context: stableContext })); + return hash(stableStringify({ ...rest, context: stableContext }) ?? ""); } /** @@ -702,6 +703,9 @@ export class EventQueue implements IEventQueue { this.generation++; this.queue = []; this.payloadHashes.clear(); + // A fresh lifecycle: the first event after opt-in ships at once, the + // way the first event of the app session does. + this.flushed = false; if (this.timer) { clearTimeout(this.timer); diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 1361f42..3ddff09 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -1,28 +1,75 @@ import { + SESSION_WALLET_DETECTED_AT_KEY, SESSION_WALLET_DETECTED_KEY, + SESSION_WALLET_IDENTIFIED_AT_KEY, SESSION_WALLET_IDENTIFIED_KEY, - SESSION_WALLET_MARKED_AT_KEY, WALLET_MARKER_TTL_MS, } from "../../constants"; +import type { IFormoEventProperties } from "../../types"; +import { hash, stableStringify } from "../../utils/hash"; import { storage } from "../storage"; import { logger } from "../logger"; export { SESSION_WALLET_DETECTED_KEY, SESSION_WALLET_IDENTIFIED_KEY }; +/** Newest entries win once a set is full, as with the web SDK's cookies. */ +const MAX_MARKER_ENTRIES = 20; + +/** Written by versions that shared one expiry between both sets. */ +const LEGACY_WALLET_MARKED_AT_KEY = "wallet_marked_at"; + +/** + * One persisted marker set: its entries, and when it was last written. + * `at` is 0 while the set is empty. + */ +type MarkerSet = { + key: string; + atKey: string; + entries: Set; + at: number; +}; + +/** + * Short fingerprint of an identify's properties, folded into the dedup key + * so a re-identify with changed properties is sent again. Empty when there + * are no properties or nothing would go on the wire. + */ +function fingerprintProperties(properties?: IFormoEventProperties): string { + if (!properties) return ""; + try { + const canonical = stableStringify(properties); + if (canonical === undefined || canonical === "{}") return ""; + return hash(canonical).slice(0, 16); + } catch (error) { + // Reading a property can run user code. Fail closed to a constant so + // dedup degrades to once per wallet instead of losing the identify. + logger.warn("Session: failed to fingerprint identify properties", error); + return "nohash"; + } +} + /** * Session manager for tracking wallet detection and identification. * Persists to storage to avoid duplicate detection/identification events. * - * Storage here is persistent, unlike a browser session cookie, so the - * markers carry their own expiry: a day from the last write, the lifetime - * of the web SDK's marker cookie. Without it a marker written on install - * would suppress detect for the life of the app. + * Storage here is persistent, unlike a browser session cookie, so each + * marker set carries its own expiry: a day from its own last write, the + * lifetime of the matching web SDK cookie. Without it a marker written on + * install would suppress detect for the life of the app. */ export class FormoAnalyticsSession { - private detectedWallets: Set = new Set(); - private identifiedWallets: Set = new Set(); - /** When the markers were last written, 0 when there are none. */ - private markedAt = 0; + private detected: MarkerSet = { + key: SESSION_WALLET_DETECTED_KEY, + atKey: SESSION_WALLET_DETECTED_AT_KEY, + entries: new Set(), + at: 0, + }; + private identified: MarkerSet = { + key: SESSION_WALLET_IDENTIFIED_KEY, + atKey: SESSION_WALLET_IDENTIFIED_AT_KEY, + entries: new Set(), + at: 0, + }; constructor() { this.loadFromStorage(); @@ -33,95 +80,171 @@ export class FormoAnalyticsSession { */ private loadFromStorage(): void { try { - const markedAtRaw = storage().get(SESSION_WALLET_MARKED_AT_KEY); - const markedAt = markedAtRaw ? parseInt(markedAtRaw, 10) : 0; - if (markedAt && Date.now() - markedAt > WALLET_MARKER_TTL_MS) { - this.clear(); - return; - } - this.markedAt = markedAt; - const detected = storage().get(SESSION_WALLET_DETECTED_KEY); - if (detected) { - const parsed = JSON.parse(detected) as string[]; - this.detectedWallets = new Set(parsed); + if (storage().get(LEGACY_WALLET_MARKED_AT_KEY)) { + storage().remove(LEGACY_WALLET_MARKED_AT_KEY); } + this.loadSet(this.detected); + this.loadSet(this.identified); + } catch (error) { + logger.debug("Session: Failed to load from storage", error); + } + } - const identified = storage().get(SESSION_WALLET_IDENTIFIED_KEY); - if (identified) { - const parsed = JSON.parse(identified) as string[]; - this.identifiedWallets = new Set(parsed); - } + private loadSet(set: MarkerSet): void { + const raw = storage().get(set.key); + if (!raw) return; + const parsed = JSON.parse(raw) as string[]; + set.entries = new Set( + parsed.slice(-MAX_MARKER_ENTRIES).map((entry) => this.migrateEntry(set, entry)) + ); + if (set.entries.size === 0) return; + set.at = this.readTimestamp(set); + if (!set.at) { // Markers written by a version without the timestamp get their day // from now; otherwise they would never expire. - if (!this.markedAt && (this.detectedWallets.size || this.identifiedWallets.size)) { - this.markedAt = Date.now(); - storage().set(SESSION_WALLET_MARKED_AT_KEY, String(this.markedAt)); - } - } catch (error) { - logger.debug("Session: Failed to load from storage", error); + set.at = Date.now(); + storage().set(set.atKey, String(set.at)); } + this.expireIfStale(set); + } + + /** An identified key written before user id and properties joined it. */ + private migrateEntry(set: MarkerSet, entry: string): string { + if (set !== this.identified) return entry; + return entry.split(":").length === 2 ? `${entry}::` : entry; } /** - * Save session state to storage + * The stored timestamp, or 0 when it is missing or unusable. A future + * timestamp is clamped to now: a clock set forward and then corrected + * must not keep a marker alive past its day. */ - private saveToStorage(): void { + private readTimestamp(set: MarkerSet): number { + const raw = storage().get(set.atKey); + const parsed = raw ? parseInt(raw, 10) : 0; + if (!Number.isFinite(parsed) || parsed <= 0) return 0; + const now = Date.now(); + if (parsed > now) { + storage().set(set.atKey, String(now)); + return now; + } + return parsed; + } + + private saveSet(set: MarkerSet): void { try { // Every write renews the day, as each write of the web cookie does. - this.markedAt = Date.now(); - storage().set(SESSION_WALLET_MARKED_AT_KEY, String(this.markedAt)); - storage().set( - SESSION_WALLET_DETECTED_KEY, - JSON.stringify(Array.from(this.detectedWallets)) - ); - storage().set( - SESSION_WALLET_IDENTIFIED_KEY, - JSON.stringify(Array.from(this.identifiedWallets)) - ); + set.at = Date.now(); + storage().set(set.atKey, String(set.at)); + storage().set(set.key, JSON.stringify(Array.from(set.entries))); } catch (error) { logger.debug("Session: Failed to save to storage", error); } } + private addEntry(set: MarkerSet, entry: string): void { + set.entries.add(entry); + for (const oldest of set.entries) { + if (set.entries.size <= MAX_MARKER_ENTRIES) break; + set.entries.delete(oldest); + } + this.saveSet(set); + } + + /** Drop the set once a day has passed since its last write. */ + private expireIfStale(set: MarkerSet): void { + if (!set.at) return; + const now = Date.now(); + if (set.at > now) { + set.at = now; + storage().set(set.atKey, String(now)); + return; + } + if (now - set.at > WALLET_MARKER_TTL_MS) { + this.dropSet(set); + } + } + + private dropSet(set: MarkerSet): void { + set.entries.clear(); + set.at = 0; + storage().remove(set.key); + storage().remove(set.atKey); + } + /** * Check if a wallet has been detected in this session */ public isWalletDetected(rdns: string): boolean { - this.expireIfStale(); - return this.detectedWallets.has(rdns); + this.expireIfStale(this.detected); + return this.detected.entries.has(rdns); } /** * Mark a wallet as detected */ public markWalletDetected(rdns: string): void { - this.detectedWallets.add(rdns); - this.saveToStorage(); + this.expireIfStale(this.detected); + if (this.detected.entries.has(rdns)) return; + this.addEntry(this.detected, rdns); } /** - * Check if a wallet + address combination has been identified + * The dedup key names the wallet, the user id and the properties, so an + * identify that changes any of them is sent again. The prefix names the + * wallet alone; marking replaces that wallet's earlier entries. */ - public isWalletIdentified(address: string, rdns: string): boolean { - this.expireIfStale(); - const key = `${address.toLowerCase()}:${rdns}`; - return this.identifiedWallets.has(key); + private buildIdentificationKey( + address: string, + rdns: string, + userId?: string, + properties?: IFormoEventProperties + ): { key: string; prefix: string } { + const prefix = `${address.toLowerCase()}:${rdns}`; + const key = `${prefix}:${userId ?? ""}:${fingerprintProperties(properties)}`; + return { key, prefix }; } - /** Drop every marker once a day has passed since the last write. */ - private expireIfStale(): void { - if (this.markedAt && Date.now() - this.markedAt > WALLET_MARKER_TTL_MS) { - this.clear(); - } + /** + * Check if a wallet + address + user + properties combination has been + * identified + */ + public isWalletIdentified( + address: string, + rdns: string, + userId?: string, + properties?: IFormoEventProperties + ): boolean { + this.expireIfStale(this.identified); + const { key } = this.buildIdentificationKey(address, rdns, userId, properties); + return this.identified.entries.has(key); } /** - * Mark a wallet + address combination as identified + * Mark a wallet + address + user + properties combination as identified */ - public markWalletIdentified(address: string, rdns: string): void { - const key = `${address.toLowerCase()}:${rdns}`; - this.identifiedWallets.add(key); - this.saveToStorage(); + public markWalletIdentified( + address: string, + rdns: string, + userId?: string, + properties?: IFormoEventProperties + ): void { + this.expireIfStale(this.identified); + const { key, prefix } = this.buildIdentificationKey( + address, + rdns, + userId, + properties + ); + if (this.identified.entries.has(key)) return; + // Keep only the wallet's latest state. Otherwise a profile that reverts + // to an earlier value would match the stale entry and send nothing. + for (const entry of this.identified.entries) { + if (entry === prefix || entry.startsWith(`${prefix}:`)) { + this.identified.entries.delete(entry); + } + } + this.addEntry(this.identified, key); } /** @@ -129,24 +252,14 @@ export class FormoAnalyticsSession { * identifies again. The detect markers stay: the wallets are still known. */ public clearIdentified(): void { - this.identifiedWallets.clear(); - storage().remove(SESSION_WALLET_IDENTIFIED_KEY); - // No marker left: the next one starts a fresh day. - if (this.detectedWallets.size === 0) { - this.markedAt = 0; - storage().remove(SESSION_WALLET_MARKED_AT_KEY); - } + this.dropSet(this.identified); } /** * Clear all session data */ public clear(): void { - this.detectedWallets.clear(); - this.identifiedWallets.clear(); - this.markedAt = 0; - storage().remove(SESSION_WALLET_DETECTED_KEY); - storage().remove(SESSION_WALLET_IDENTIFIED_KEY); - storage().remove(SESSION_WALLET_MARKED_AT_KEY); + this.dropSet(this.detected); + this.dropSet(this.identified); } } diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 4be671a..5fa1ed2 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -15,6 +15,57 @@ export function hash(input: string): string { return bytesToHex(hashBytes); } +/** + * Serialize a value so equal values give the same string whatever the key + * insertion order. Object keys are sorted recursively; arrays keep their + * order. Values follow JSON.stringify: undefined, functions and symbols are + * omitted from objects and become null in arrays, non-finite numbers become + * null, and toJSON() is honored. Returns undefined when JSON would omit the + * value, and throws where JSON.stringify throws (BigInt, cycles), so a + * payload that cannot go on the wire fails at the same point as before. + */ +export function stableStringify( + value: unknown, + seen: Set = new Set() +): string | undefined { + if (value === undefined) return undefined; + if (typeof value === "function") return undefined; + if (typeof value === "symbol") return undefined; + if (value === null) return "null"; + if (typeof value === "number") { + return Number.isFinite(value) ? JSON.stringify(value) : "null"; + } + if (typeof value !== "object") return JSON.stringify(value); + + if (seen.has(value)) { + throw new TypeError("Converting circular structure to JSON"); + } + seen.add(value); + try { + const maybeToJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof maybeToJSON === "function") { + return stableStringify((maybeToJSON as () => unknown).call(value), seen); + } + if (Array.isArray(value)) { + return `[${value + .map((item) => stableStringify(item, seen) ?? "null") + .join(",")}]`; + } + const record = value as Record; + const parts: string[] = []; + for (const key of Object.keys(record).sort()) { + const encoded = stableStringify(record[key], seen); + if (encoded === undefined) continue; + parts.push(`${JSON.stringify(key)}:${encoded}`); + } + return `{${parts.join(",")}}`; + } finally { + // Only cycles are guarded. The same object used twice as siblings must + // serialize the same both times. + seen.delete(value); + } +} + // Monotonic counter for the no-Web-Crypto fallback below. Guarantees the // fallback produces distinct ids even for calls within the same millisecond. let uuidFallbackCounter = 0; From d63009c540ed5d76285deed16c79a7688343a7de Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 10:59:28 +0700 Subject: [PATCH 02/17] fix: supersede identify markers per wallet-user, as on web Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/session.test.ts | 17 ++++++++++++++--- src/lib/session/index.ts | 13 +++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index fd7b71d..8615e69 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -129,13 +129,24 @@ describe('FormoAnalyticsSession', () => { expect(session.isWalletIdentified(address, rdns, undefined, {})).toBe(true); }); - it('keeps only the latest state of a wallet', () => { + it('keeps one entry per wallet-user, as on web', () => { session.markWalletIdentified(address, rdns, 'user-a'); session.markWalletIdentified(address, rdns, 'user-b'); + // Both users stay identified on this wallet; a return to user-a is deduped. expect(session.isWalletIdentified(address, rdns, 'user-b')).toBe(true); - // The wallet's last identify carried user-b, so user-a is a change again. - expect(session.isWalletIdentified(address, rdns, 'user-a')).toBe(false); + expect(session.isWalletIdentified(address, rdns, 'user-a')).toBe(true); + const stored = JSON.parse(mockStorage.set.mock.calls.at(-1)![1]) as string[]; + expect(stored).toHaveLength(2); + }); + + it('keeps only the latest profile of a wallet-user', () => { + session.markWalletIdentified(address, rdns, 'user-a', { plan: 'free' }); + session.markWalletIdentified(address, rdns, 'user-a', { plan: 'pro' }); + + expect(session.isWalletIdentified(address, rdns, 'user-a', { plan: 'pro' })).toBe(true); + // The last identify carried plan pro, so a return to free is a change again. + expect(session.isWalletIdentified(address, rdns, 'user-a', { plan: 'free' })).toBe(false); const stored = JSON.parse(mockStorage.set.mock.calls.at(-1)![1]) as string[]; expect(stored).toHaveLength(1); }); diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 3ddff09..11a7812 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -192,7 +192,8 @@ export class FormoAnalyticsSession { /** * The dedup key names the wallet, the user id and the properties, so an * identify that changes any of them is sent again. The prefix names the - * wallet alone; marking replaces that wallet's earlier entries. + * wallet-user; marking replaces that wallet-user's earlier state, as on + * web, so dedup means "the same as this wallet-user's last identify". */ private buildIdentificationKey( address: string, @@ -200,8 +201,8 @@ export class FormoAnalyticsSession { userId?: string, properties?: IFormoEventProperties ): { key: string; prefix: string } { - const prefix = `${address.toLowerCase()}:${rdns}`; - const key = `${prefix}:${userId ?? ""}:${fingerprintProperties(properties)}`; + const prefix = `${address.toLowerCase()}:${rdns}:${userId ?? ""}`; + const key = `${prefix}:${fingerprintProperties(properties)}`; return { key, prefix }; } @@ -237,10 +238,10 @@ export class FormoAnalyticsSession { properties ); if (this.identified.entries.has(key)) return; - // Keep only the wallet's latest state. Otherwise a profile that reverts - // to an earlier value would match the stale entry and send nothing. + // Keep only the wallet-user's latest state. Otherwise a profile that + // reverts to an earlier value would match the stale entry and send nothing. for (const entry of this.identified.entries) { - if (entry === prefix || entry.startsWith(`${prefix}:`)) { + if (entry.startsWith(`${prefix}:`)) { this.identified.entries.delete(entry); } } From b4d0ba57c396f0863ba65fba67a959a41e920b9e Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 11:09:55 +0700 Subject: [PATCH 03/17] fix: sparse arrays and toJSON in stable serialization; legacy marker expiry kept; delimiter-safe user ids Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/session.test.ts | 24 ++++++++++++++++++++++++ src/lib/session/index.ts | 25 +++++++++++++++---------- src/utils/hash.ts | 19 ++++++++++++------- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 8615e69..659ed4c 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -140,6 +140,14 @@ describe('FormoAnalyticsSession', () => { expect(stored).toHaveLength(2); }); + it('does not let a ":" in one user id match another wallet-user', () => { + session.markWalletIdentified(address, rdns, 'a:b'); + session.markWalletIdentified(address, rdns, 'a'); + + expect(session.isWalletIdentified(address, rdns, 'a:b')).toBe(true); + expect(session.isWalletIdentified(address, rdns, 'a')).toBe(true); + }); + it('keeps only the latest profile of a wallet-user', () => { session.markWalletIdentified(address, rdns, 'user-a', { plan: 'free' }); session.markWalletIdentified(address, rdns, 'user-a', { plan: 'pro' }); @@ -284,6 +292,22 @@ describe('FormoAnalyticsSession', () => { expect(fresh.isWalletIdentified('0x123', 'io.metamask')).toBe(true); }); + it('keeps the shared legacy timestamp, so markers already past their day expire now', () => { + const DAY = 24 * 60 * 60 * 1000; + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + mockStorage.get.mockImplementation((key: string) => { + if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); + if (key === 'wallet_marked_at') return String(Date.now() - DAY - 1000); // shared, stale + return null; + }); + + const upgraded = new FormoAnalyticsSession(); + + expect(upgraded.isWalletDetected('io.metamask')).toBe(false); + expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); + jest.useRealTimers(); + }); + it('stamps markers written by a version without the timestamp, so they expire too', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); mockStorage.get.mockImplementation((key: string) => { diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 11a7812..5731c01 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -80,17 +80,20 @@ export class FormoAnalyticsSession { */ private loadFromStorage(): void { try { - if (storage().get(LEGACY_WALLET_MARKED_AT_KEY)) { - storage().remove(LEGACY_WALLET_MARKED_AT_KEY); - } - this.loadSet(this.detected); - this.loadSet(this.identified); + // A version that shared one expiry between both sets: its timestamp + // seeds each set that has none, so markers already past their day + // still expire now rather than a day from now. + const legacyAtRaw = storage().get(LEGACY_WALLET_MARKED_AT_KEY); + const legacyAt = legacyAtRaw ? parseInt(legacyAtRaw, 10) : 0; + if (legacyAtRaw) storage().remove(LEGACY_WALLET_MARKED_AT_KEY); + this.loadSet(this.detected, legacyAt); + this.loadSet(this.identified, legacyAt); } catch (error) { logger.debug("Session: Failed to load from storage", error); } } - private loadSet(set: MarkerSet): void { + private loadSet(set: MarkerSet, legacyAt = 0): void { const raw = storage().get(set.key); if (!raw) return; const parsed = JSON.parse(raw) as string[]; @@ -100,9 +103,10 @@ export class FormoAnalyticsSession { if (set.entries.size === 0) return; set.at = this.readTimestamp(set); if (!set.at) { - // Markers written by a version without the timestamp get their day - // from now; otherwise they would never expire. - set.at = Date.now(); + // Markers written by a version without a per-set timestamp keep the + // shared one if there was one, else get their day from now; either + // way they expire. + set.at = Number.isFinite(legacyAt) && legacyAt > 0 ? Math.min(legacyAt, Date.now()) : Date.now(); storage().set(set.atKey, String(set.at)); } this.expireIfStale(set); @@ -201,7 +205,8 @@ export class FormoAnalyticsSession { userId?: string, properties?: IFormoEventProperties ): { key: string; prefix: string } { - const prefix = `${address.toLowerCase()}:${rdns}:${userId ?? ""}`; + // The user id is encoded so a ":" inside it cannot read as a delimiter. + const prefix = `${address.toLowerCase()}:${rdns}:${userId === undefined ? "" : encodeURIComponent(userId)}`; const key = `${prefix}:${fingerprintProperties(properties)}`; return { key, prefix }; } diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 5fa1ed2..68f089d 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -37,19 +37,24 @@ export function stableStringify( } if (typeof value !== "object") return JSON.stringify(value); + // toJSON() first, as JSON.stringify does; the cycle check applies to what + // it returns, since that is what goes on the wire. + const maybeToJSON = (value as { toJSON?: unknown }).toJSON; + if (typeof maybeToJSON === "function") { + return stableStringify((maybeToJSON as () => unknown).call(value), seen); + } if (seen.has(value)) { throw new TypeError("Converting circular structure to JSON"); } seen.add(value); try { - const maybeToJSON = (value as { toJSON?: unknown }).toJSON; - if (typeof maybeToJSON === "function") { - return stableStringify((maybeToJSON as () => unknown).call(value), seen); - } if (Array.isArray(value)) { - return `[${value - .map((item) => stableStringify(item, seen) ?? "null") - .join(",")}]`; + // By index, so a hole serializes as null the way JSON.stringify does. + const items: string[] = []; + for (let i = 0; i < value.length; i++) { + items.push(stableStringify(value[i], seen) ?? "null"); + } + return `[${items.join(",")}]`; } const record = value as Record; const parts: string[] = []; From f795ce49a67e46393c497a7e31596220b2da0c70 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 11:11:19 +0700 Subject: [PATCH 04/17] test: legacy markers keep the shared timestamp; sparse arrays and toJSON in stableStringify Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 11 +++++++++++ src/__tests__/session.test.ts | 10 ++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 89cc05d..9030a24 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -113,6 +113,17 @@ describe('hash utilities', () => { }); describe('stableStringify()', () => { + it('serializes a sparse array hole as null, as JSON.stringify does', () => { + const sparse: unknown[] = []; + sparse[2] = 1; + expect(stableStringify({ a: sparse })).toBe(JSON.stringify({ a: sparse })); + }); + + it('applies toJSON before the cycle check', () => { + const shared = { toJSON: () => 'x' }; + expect(stableStringify({ a: shared, b: shared })).toBe(JSON.stringify({ a: shared, b: shared })); + }); + it('sorts object keys recursively', () => { expect(stableStringify({ b: 1, a: { d: 2, c: 3 } })).toBe( '{"a":{"c":3,"d":2},"b":1}' diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 659ed4c..9507d51 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -308,19 +308,21 @@ describe('FormoAnalyticsSession', () => { jest.useRealTimers(); }); - it('stamps markers written by a version without the timestamp, so they expire too', () => { + it('stamps markers written by a version without per-set timestamps, so they expire too', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + const shared = Date.now() - 1000; mockStorage.get.mockImplementation((key: string) => { if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); if (key === 'wallet_identified') return JSON.stringify(['0x123:io.metamask']); - if (key === 'wallet_marked_at') return String(Date.now() - 1000); + if (key === 'wallet_marked_at') return String(shared); return null; // no per-set timestamp: written before it existed }); const upgraded = new FormoAnalyticsSession(); - expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); - expect(mockStorage.set).toHaveBeenCalledWith('wallet_identified_at', String(Date.now())); + // Each set keeps the shared timestamp, so its remaining day is unchanged. + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(shared)); + expect(mockStorage.set).toHaveBeenCalledWith('wallet_identified_at', String(shared)); expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); expect(upgraded.isWalletDetected('io.metamask')).toBe(true); expect(upgraded.isWalletIdentified('0x123', 'io.metamask')).toBe(true); From 2186bee58402e1ea56a08dacacb3c06eb69aa496 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 11:24:27 +0700 Subject: [PATCH 05/17] fix: canonical serialization follows JSON.stringify for wrappers, toJSON and cycles; delimiter-safe user ids never throw Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 18 +++++++++ src/__tests__/session.test.ts | 5 +++ src/lib/session/index.ts | 6 ++- src/utils/hash.ts | 71 ++++++++++++++--------------------- 4 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 9030a24..4490c2b 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -113,6 +113,24 @@ describe('hash utilities', () => { }); describe('stableStringify()', () => { + it('unboxes primitive wrappers, as JSON.stringify does', () => { + const a = { amount: new Number(1), ok: new Boolean(true), s: new String('x') }; + expect(stableStringify(a)).toBe(JSON.stringify(a)); + expect(stableStringify({ amount: new Number(1) })).not.toBe(stableStringify({ amount: new Number(2) })); + }); + + it('serializes a self-returning toJSON by its fields, as JSON.stringify does', () => { + const self: Record = { b: 2, a: 1 }; + self.toJSON = function () { return this; }; + expect(stableStringify(self)).toBe('{"a":1,"b":2}'); + }); + + it('throws on a cycle, as JSON.stringify does', () => { + const cyc: Record = { a: 1 }; + cyc.self = cyc; + expect(() => stableStringify(cyc)).toThrow(TypeError); + }); + it('serializes a sparse array hole as null, as JSON.stringify does', () => { const sparse: unknown[] = []; sparse[2] = 1; diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 9507d51..9959a0c 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -140,6 +140,11 @@ describe('FormoAnalyticsSession', () => { expect(stored).toHaveLength(2); }); + it('accepts a user id with a lone surrogate without throwing', () => { + expect(() => session.markWalletIdentified(address, rdns, 'bad\ud800id')).not.toThrow(); + expect(session.isWalletIdentified(address, rdns, 'bad\ud800id')).toBe(true); + }); + it('does not let a ":" in one user id match another wallet-user', () => { session.markWalletIdentified(address, rdns, 'a:b'); session.markWalletIdentified(address, rdns, 'a'); diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 5731c01..9c85924 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -205,8 +205,10 @@ export class FormoAnalyticsSession { userId?: string, properties?: IFormoEventProperties ): { key: string; prefix: string } { - // The user id is encoded so a ":" inside it cannot read as a delimiter. - const prefix = `${address.toLowerCase()}:${rdns}:${userId === undefined ? "" : encodeURIComponent(userId)}`; + // The user id is escaped so a ":" inside it cannot read as a delimiter. + // A plain replace, total over every string, unlike encodeURIComponent. + const safeUser = userId === undefined ? "" : userId.replace(/%/g, "%25").replace(/:/g, "%3A"); + const prefix = `${address.toLowerCase()}:${rdns}:${safeUser}`; const key = `${prefix}:${fingerprintProperties(properties)}`; return { key, prefix }; } diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 68f089d..d249491 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -16,58 +16,43 @@ export function hash(input: string): string { } /** - * Serialize a value so equal values give the same string whatever the key - * insertion order. Object keys are sorted recursively; arrays keep their - * order. Values follow JSON.stringify: undefined, functions and symbols are - * omitted from objects and become null in arrays, non-finite numbers become - * null, and toJSON() is honored. Returns undefined when JSON would omit the - * value, and throws where JSON.stringify throws (BigInt, cycles), so a - * payload that cannot go on the wire fails at the same point as before. + * JSON with object keys in sorted order at every depth, so two property + * bags built in a different order serialize the same. Otherwise it follows + * JSON.stringify: toJSON() is honored, boxed primitives unbox, arrays keep + * their order and holes become null, undefined values are omitted, and a + * cycle throws. Returns undefined where JSON.stringify would. */ -export function stableStringify( - value: unknown, - seen: Set = new Set() -): string | undefined { - if (value === undefined) return undefined; - if (typeof value === "function") return undefined; - if (typeof value === "symbol") return undefined; - if (value === null) return "null"; - if (typeof value === "number") { - return Number.isFinite(value) ? JSON.stringify(value) : "null"; - } - if (typeof value !== "object") return JSON.stringify(value); +export function stableStringify(value: unknown): string | undefined { + const out = canonical(value, new Set()); + return out === undefined ? undefined : JSON.stringify(out); +} - // toJSON() first, as JSON.stringify does; the cycle check applies to what - // it returns, since that is what goes on the wire. - const maybeToJSON = (value as { toJSON?: unknown }).toJSON; - if (typeof maybeToJSON === "function") { - return stableStringify((maybeToJSON as () => unknown).call(value), seen); +/** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ +function canonical(value: unknown, stack: Set): unknown { + if (value === null || typeof value !== "object") return value; + const withToJSON = value as { toJSON?: unknown }; + if (typeof withToJSON.toJSON === "function") { + const out = (withToJSON.toJSON as () => unknown).call(value); + // A toJSON() that returns its own object serializes by its fields. + if (out !== value) return canonical(out, stack); } - if (seen.has(value)) { - throw new TypeError("Converting circular structure to JSON"); + if (value instanceof Number || value instanceof String || value instanceof Boolean) { + return value.valueOf(); } - seen.add(value); + if (stack.has(value)) throw new TypeError("Converting circular structure to JSON"); + stack.add(value); try { if (Array.isArray(value)) { - // By index, so a hole serializes as null the way JSON.stringify does. - const items: string[] = []; - for (let i = 0; i < value.length; i++) { - items.push(stableStringify(value[i], seen) ?? "null"); - } - return `[${items.join(",")}]`; + const items: unknown[] = []; + for (let i = 0; i < value.length; i++) items.push(canonical(value[i], stack)); + return items; } const record = value as Record; - const parts: string[] = []; - for (const key of Object.keys(record).sort()) { - const encoded = stableStringify(record[key], seen); - if (encoded === undefined) continue; - parts.push(`${JSON.stringify(key)}:${encoded}`); - } - return `{${parts.join(",")}}`; + const sorted: Record = Object.create(null); + for (const key of Object.keys(record).sort()) sorted[key] = canonical(record[key], stack); + return sorted; } finally { - // Only cycles are guarded. The same object used twice as siblings must - // serialize the same both times. - seen.delete(value); + stack.delete(value); } } From 80fbade73df2a605718dc5dc30dab36629c96968 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 13:53:03 +0700 Subject: [PATCH 06/17] fix: toJSON gets its property key, wrappers unbox through built-ins; identify dedups on the effective user Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/FormoAnalytics.ts | 6 ++++-- src/__tests__/hash.test.ts | 9 +++++++++ src/utils/hash.ts | 20 +++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 4983ee4..4846c35 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -731,8 +731,10 @@ export class FormoAnalytics implements IFormoAnalytics { } // Check for duplicate identify + // The effective user, as the wire event carries it, not only the argument. + const effectiveUserId = this.currentUserId; const isAlreadyIdentified = validAddress - ? this.session.isWalletIdentified(validAddress, rdns || "", userId, properties) + ? this.session.isWalletIdentified(validAddress, rdns || "", effectiveUserId, properties) : false; if (isAlreadyIdentified) { @@ -744,7 +746,7 @@ export class FormoAnalytics implements IFormoAnalytics { // Mark as identified if (validAddress) { - this.session.markWalletIdentified(validAddress, rdns || "", userId, properties); + this.session.markWalletIdentified(validAddress, rdns || "", effectiveUserId, properties); } await this.trackEvent( diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 4490c2b..93e8081 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -125,6 +125,15 @@ describe('hash utilities', () => { expect(stableStringify(self)).toBe('{"a":1,"b":2}'); }); + it('passes the property key to toJSON and unboxes through the built-in methods', () => { + const keyed = { toJSON: (k: string) => k }; + expect(stableStringify({ x: keyed })).toBe(JSON.stringify({ x: keyed })); + expect(stableStringify({ x: keyed })).not.toBe(stableStringify({ y: keyed })); + const boxed = new String('real') as String & { valueOf: () => string }; + boxed.valueOf = () => 'override'; + expect(stableStringify({ s: boxed })).toBe(JSON.stringify({ s: boxed })); + }); + it('throws on a cycle, as JSON.stringify does', () => { const cyc: Record = { a: 1 }; cyc.self = cyc; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index d249491..b002c97 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -23,33 +23,35 @@ export function hash(input: string): string { * cycle throws. Returns undefined where JSON.stringify would. */ export function stableStringify(value: unknown): string | undefined { - const out = canonical(value, new Set()); + const out = canonical(value, "", new Set()); return out === undefined ? undefined : JSON.stringify(out); } /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ -function canonical(value: unknown, stack: Set): unknown { +function canonical(value: unknown, key: string, stack: Set): unknown { if (value === null || typeof value !== "object") return value; const withToJSON = value as { toJSON?: unknown }; if (typeof withToJSON.toJSON === "function") { - const out = (withToJSON.toJSON as () => unknown).call(value); + // With the property key, as JSON.stringify passes it. + const out = (withToJSON.toJSON as (k: string) => unknown).call(value, key); // A toJSON() that returns its own object serializes by its fields. - if (out !== value) return canonical(out, stack); - } - if (value instanceof Number || value instanceof String || value instanceof Boolean) { - return value.valueOf(); + if (out !== value) return canonical(out, key, stack); } + // Unboxed through the built-in methods, not an override on the instance. + if (value instanceof Number) return Number.prototype.valueOf.call(value); + if (value instanceof String) return String.prototype.valueOf.call(value); + if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value); if (stack.has(value)) throw new TypeError("Converting circular structure to JSON"); stack.add(value); try { if (Array.isArray(value)) { const items: unknown[] = []; - for (let i = 0; i < value.length; i++) items.push(canonical(value[i], stack)); + for (let i = 0; i < value.length; i++) items.push(canonical(value[i], String(i), stack)); return items; } const record = value as Record; const sorted: Record = Object.create(null); - for (const key of Object.keys(record).sort()) sorted[key] = canonical(record[key], stack); + for (const k of Object.keys(record).sort()) sorted[k] = canonical(record[k], k, stack); return sorted; } finally { stack.delete(value); From 34592a0262ddf32655f71719b843d14cc6116a1a Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 13:53:46 +0700 Subject: [PATCH 07/17] test: silence the wrapper-type lint in the boxed String case Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 93e8081..882c9ed 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -129,6 +129,7 @@ describe('hash utilities', () => { const keyed = { toJSON: (k: string) => k }; expect(stableStringify({ x: keyed })).toBe(JSON.stringify({ x: keyed })); expect(stableStringify({ x: keyed })).not.toBe(stableStringify({ y: keyed })); + // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types const boxed = new String('real') as String & { valueOf: () => string }; boxed.valueOf = () => 'override'; expect(stableStringify({ s: boxed })).toBe(JSON.stringify({ s: boxed })); From 238fea69c1cebeab8de52159a9f454e70df65feb Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 14:05:47 +0700 Subject: [PATCH 08/17] fix: toJSON runs once per property; a truncated or migrated marker set is written back on load Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 10 ++++++++++ src/lib/session/index.ts | 6 ++++++ src/utils/hash.ts | 22 ++++++++++++++-------- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 882c9ed..9d24284 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -135,6 +135,16 @@ describe('hash utilities', () => { expect(stableStringify({ s: boxed })).toBe(JSON.stringify({ s: boxed })); }); + it('runs toJSON once per property, as JSON.stringify does', () => { + const b = { toJSON: () => ({ v: 2 }) }; + const a = { toJSON: () => b }; + // JSON.stringify serializes what a.toJSON returned without calling b.toJSON. + expect(stableStringify({ p: a })).toBe(JSON.stringify({ p: a })); + const x: Record = {}; const y: Record = {}; + x.toJSON = () => y; y.toJSON = () => x; // mutually returning hooks + expect(stableStringify({ p: x })).toBe(JSON.stringify({ p: x })); + }); + it('throws on a cycle, as JSON.stringify does', () => { const cyc: Record = { a: 1 }; cyc.self = cyc; diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 9c85924..09483ea 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -101,6 +101,12 @@ export class FormoAnalyticsSession { parsed.slice(-MAX_MARKER_ENTRIES).map((entry) => this.migrateEntry(set, entry)) ); if (set.entries.size === 0) return; + // A truncated or migrated set is written back, or every cold start + // would load the legacy value again. + const migrated = Array.from(set.entries); + if (migrated.length !== parsed.length || migrated.some((e, i) => e !== parsed[i])) { + storage().set(set.key, JSON.stringify(migrated)); + } set.at = this.readTimestamp(set); if (!set.at) { // Markers written by a version without a per-set timestamp keep the diff --git a/src/utils/hash.ts b/src/utils/hash.ts index b002c97..504311d 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -23,19 +23,20 @@ export function hash(input: string): string { * cycle throws. Returns undefined where JSON.stringify would. */ export function stableStringify(value: unknown): string | undefined { - const out = canonical(value, "", new Set()); + const out = canonical(value, "", new Set(), false); return out === undefined ? undefined : JSON.stringify(out); } /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ -function canonical(value: unknown, key: string, stack: Set): unknown { +function canonical(value: unknown, key: string, stack: Set, fromToJSON: boolean): unknown { if (value === null || typeof value !== "object") return value; const withToJSON = value as { toJSON?: unknown }; - if (typeof withToJSON.toJSON === "function") { - // With the property key, as JSON.stringify passes it. + // toJSON() runs once per property, as in JSON.stringify: what it returns + // is serialized as-is, its own hook included, and a hook that returns its + // own object serializes by its fields. + if (!fromToJSON && typeof withToJSON.toJSON === "function") { const out = (withToJSON.toJSON as (k: string) => unknown).call(value, key); - // A toJSON() that returns its own object serializes by its fields. - if (out !== value) return canonical(out, key, stack); + if (out !== value) return canonical(out, key, stack, true); } // Unboxed through the built-in methods, not an override on the instance. if (value instanceof Number) return Number.prototype.valueOf.call(value); @@ -46,12 +47,17 @@ function canonical(value: unknown, key: string, stack: Set): unknown { try { if (Array.isArray(value)) { const items: unknown[] = []; - for (let i = 0; i < value.length; i++) items.push(canonical(value[i], String(i), stack)); + for (let i = 0; i < value.length; i++) items.push(canonical(value[i], String(i), stack, false)); return items; } const record = value as Record; const sorted: Record = Object.create(null); - for (const k of Object.keys(record).sort()) sorted[k] = canonical(record[k], k, stack); + for (const k of Object.keys(record).sort()) { + const v = record[k]; + // JSON omits these, and a toJSON function must not survive into the clone. + if (v === undefined || typeof v === "function" || typeof v === "symbol") continue; + sorted[k] = canonical(v, k, stack, false); + } return sorted; } finally { stack.delete(value); From 6939abeaaa4a6f40d4a69c66dbd1ef07193993e9 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 14:15:34 +0700 Subject: [PATCH 09/17] fix: a function value's toJSON hook is honored and the hook is read once Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 9 +++++++++ src/utils/hash.ts | 24 +++++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 9d24284..dc00fdb 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -145,6 +145,15 @@ describe('hash utilities', () => { expect(stableStringify({ p: x })).toBe(JSON.stringify({ p: x })); }); + it('honors a toJSON hook on a function value, and reads the hook once', () => { + const fn = Object.assign(() => undefined, { toJSON: () => 'fn' }); + expect(stableStringify({ f: fn })).toBe(JSON.stringify({ f: fn })); + expect(stableStringify({ g: () => undefined })).toBe(JSON.stringify({ g: () => undefined })); + let reads = 0; + const flaky = { a: 1, get toJSON() { reads++; return reads === 1 ? () => 'once' : undefined; } }; + expect(stableStringify({ p: flaky })).toBe('{"p":"once"}'); + }); + it('throws on a cycle, as JSON.stringify does', () => { const cyc: Record = { a: 1 }; cyc.self = cyc; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 504311d..b4182c8 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -29,15 +29,20 @@ export function stableStringify(value: unknown): string | undefined { /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ function canonical(value: unknown, key: string, stack: Set, fromToJSON: boolean): unknown { - if (value === null || typeof value !== "object") return value; - const withToJSON = value as { toJSON?: unknown }; + if (value === null || (typeof value !== "object" && typeof value !== "function")) return value; // toJSON() runs once per property, as in JSON.stringify: what it returns // is serialized as-is, its own hook included, and a hook that returns its - // own object serializes by its fields. - if (!fromToJSON && typeof withToJSON.toJSON === "function") { - const out = (withToJSON.toJSON as (k: string) => unknown).call(value, key); - if (out !== value) return canonical(out, key, stack, true); + // own object serializes by its fields. The hook is read once, so an + // accessor cannot hand back two different values. + if (!fromToJSON) { + const hook = (value as { toJSON?: unknown }).toJSON; + if (typeof hook === "function") { + const out = (hook as (k: string) => unknown).call(value, key); + if (out !== value) return canonical(out, key, stack, true); + } } + // A function with no hook is omitted, as JSON.stringify omits it. + if (typeof value === "function") return undefined; // Unboxed through the built-in methods, not an override on the instance. if (value instanceof Number) return Number.prototype.valueOf.call(value); if (value instanceof String) return String.prototype.valueOf.call(value); @@ -54,9 +59,10 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: const sorted: Record = Object.create(null); for (const k of Object.keys(record).sort()) { const v = record[k]; - // JSON omits these, and a toJSON function must not survive into the clone. - if (v === undefined || typeof v === "function" || typeof v === "symbol") continue; - sorted[k] = canonical(v, k, stack, false); + if (v === undefined || typeof v === "symbol") continue; + const c = canonical(v, k, stack, false); + // Omitted values (functions without a hook) never reach the clone. + if (c !== undefined) sorted[k] = c; } return sorted; } finally { From 08c81ef5846d789328a9e65be3de556c48d60ece Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 14:28:40 +0700 Subject: [PATCH 10/17] fix: toJSON hooks apply through Reflect.apply, bigint hooks included Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 13 +++++++++++++ src/utils/hash.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index dc00fdb..388b939 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -154,6 +154,19 @@ describe('hash utilities', () => { expect(stableStringify({ p: flaky })).toBe('{"p":"once"}'); }); + it('honors a BigInt.prototype.toJSON hook and an overridden call on a hook', () => { + const proto = BigInt.prototype as unknown as { toJSON?: () => string }; + proto.toJSON = function () { return this.toString(); }; + try { + expect(stableStringify({ amount: 123n })).toBe(JSON.stringify({ amount: 123n })); + } finally { + delete proto.toJSON; + } + expect(() => stableStringify({ amount: 5n })).toThrow(TypeError); + const hook = Object.assign(() => 'ok', { call: () => { throw new Error('overridden'); } }); + expect(stableStringify({ p: { toJSON: hook } })).toBe(JSON.stringify({ p: { toJSON: hook } })); + }); + it('throws on a cycle, as JSON.stringify does', () => { const cyc: Record = { a: 1 }; cyc.self = cyc; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index b4182c8..94207f9 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -29,7 +29,9 @@ export function stableStringify(value: unknown): string | undefined { /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ function canonical(value: unknown, key: string, stack: Set, fromToJSON: boolean): unknown { - if (value === null || (typeof value !== "object" && typeof value !== "function")) return value; + // Objects, functions and bigints can carry a toJSON hook, as in JSON.stringify. + const hookable = value !== null && (typeof value === "object" || typeof value === "function" || typeof value === "bigint"); + if (!hookable) return value; // toJSON() runs once per property, as in JSON.stringify: what it returns // is serialized as-is, its own hook included, and a hook that returns its // own object serializes by its fields. The hook is read once, so an @@ -37,10 +39,12 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: if (!fromToJSON) { const hook = (value as { toJSON?: unknown }).toJSON; if (typeof hook === "function") { - const out = (hook as (k: string) => unknown).call(value, key); + const out: unknown = Reflect.apply(hook, value, [key]); if (out !== value) return canonical(out, key, stack, true); } } + // A bigint without a hook is left to JSON.stringify, which throws on it. + if (typeof value === "bigint") return value; // A function with no hook is omitted, as JSON.stringify omits it. if (typeof value === "function") return undefined; // Unboxed through the built-in methods, not an override on the instance. From 414c44746f41ce2355edebdd0be1810bb737c76d Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 14:43:22 +0700 Subject: [PATCH 11/17] fix: boxed bigints reject; array length read once Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 7 +++++++ src/utils/hash.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 388b939..390d5a1 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -167,6 +167,13 @@ describe('hash utilities', () => { expect(stableStringify({ p: { toJSON: hook } })).toBe(JSON.stringify({ p: { toJSON: hook } })); }); + it('rejects a boxed bigint and reads an array length once', () => { + expect(() => stableStringify({ n: Object(BigInt(1)) })).toThrow(TypeError); + let reads = 0; + const growing = new Proxy([1, 2], { get: (t, p, r) => { if (p === 'length') { reads++; if (reads > 1) t.push(0); } return Reflect.get(t, p, r); } }); + expect(stableStringify({ a: growing })).toBe('{"a":[1,2]}'); + }); + it('throws on a cycle, as JSON.stringify does', () => { const cyc: Record = { a: 1 }; cyc.self = cyc; diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 94207f9..2295bd2 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -48,6 +48,7 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: // A function with no hook is omitted, as JSON.stringify omits it. if (typeof value === "function") return undefined; // Unboxed through the built-in methods, not an override on the instance. + if (value instanceof BigInt) return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it, as before if (value instanceof Number) return Number.prototype.valueOf.call(value); if (value instanceof String) return String.prototype.valueOf.call(value); if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value); @@ -56,7 +57,8 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: try { if (Array.isArray(value)) { const items: unknown[] = []; - for (let i = 0; i < value.length; i++) items.push(canonical(value[i], String(i), stack, false)); + const length = value.length; // read once, as JSON.stringify does + for (let i = 0; i < length; i++) items.push(canonical(value[i], String(i), stack, false)); return items; } const record = value as Record; From 1e63d2715a19edc780ba0b788f4432355c4df916 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 15:06:13 +0700 Subject: [PATCH 12/17] fix: guard the BigInt check for runtimes without BigInt Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/utils/hash.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 2295bd2..07e8880 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -48,7 +48,10 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: // A function with no hook is omitted, as JSON.stringify omits it. if (typeof value === "function") return undefined; // Unboxed through the built-in methods, not an override on the instance. - if (value instanceof BigInt) return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it, as before + // Guarded: a runtime without BigInt must not throw here on every object. + if (typeof BigInt !== "undefined" && value instanceof BigInt) { + return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it, as before + } if (value instanceof Number) return Number.prototype.valueOf.call(value); if (value instanceof String) return String.prototype.valueOf.call(value); if (value instanceof Boolean) return Boolean.prototype.valueOf.call(value); From e1eb46d752c763f4aeb32ec6f836f1f8b9c97c4b Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 15:52:39 +0700 Subject: [PATCH 13/17] refactor: share the marker set helpers; simplify the identify dedup and the serializer entry point Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/FormoAnalytics.ts | 29 ++++------ src/__tests__/hash.test.ts | 7 +-- src/__tests__/session.test.ts | 106 +++++++++++++--------------------- src/lib/session/index.ts | 84 ++++++++++++--------------- src/utils/hash.ts | 3 +- 5 files changed, 88 insertions(+), 141 deletions(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 4846c35..7a4a2c7 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -705,7 +705,7 @@ export class FormoAnalytics implements IFormoAnalytics { // a marker written on an excluded chain would silence a later // identify on an allowed one. if (!this.shouldTrack()) { - logger.info("identify() skipped: tracking is suppressed for this wallet or chain"); + logger.info("identify() skipped: tracking is suppressed for this visitor or chain"); return; } @@ -730,23 +730,16 @@ export class FormoAnalytics implements IFormoAnalytics { storage().set(SESSION_USER_ID_KEY, userId); } - // Check for duplicate identify - // The effective user, as the wire event carries it, not only the argument. - const effectiveUserId = this.currentUserId; - const isAlreadyIdentified = validAddress - ? this.session.isWalletIdentified(validAddress, rdns || "", effectiveUserId, properties) - : false; - - if (isAlreadyIdentified) { - logger.info( - `Identify: Wallet ${providerName || "Unknown"} with address ${validAddress} already identified` - ); - return; - } - - // Mark as identified + // Dedup on the effective user, as the wire event carries it, not only + // the argument: a re-identify that changes the user is sent again. if (validAddress) { - this.session.markWalletIdentified(validAddress, rdns || "", effectiveUserId, properties); + if (this.session.isWalletIdentified(validAddress, rdns || "", this.currentUserId, properties)) { + logger.info( + `Identify: Wallet ${providerName || "Unknown"} with address ${validAddress} already identified` + ); + return; + } + this.session.markWalletIdentified(validAddress, rdns || "", this.currentUserId, properties); } await this.trackEvent( @@ -773,7 +766,7 @@ export class FormoAnalytics implements IFormoAnalytics { // The full policy, not consent alone: a detect refused by the chain gate // after the marker is written would silence the wallet for the session. if (!this.shouldTrack()) { - logger.info("detect() skipped: tracking is suppressed for this wallet or chain"); + logger.info("detect() skipped: tracking is suppressed for this visitor or chain"); return; } if (this.session.isWalletDetected(rdns)) { diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 390d5a1..a543b25 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -152,6 +152,7 @@ describe('hash utilities', () => { let reads = 0; const flaky = { a: 1, get toJSON() { reads++; return reads === 1 ? () => 'once' : undefined; } }; expect(stableStringify({ p: flaky })).toBe('{"p":"once"}'); + expect(reads).toBe(1); }); it('honors a BigInt.prototype.toJSON hook and an overridden call on a hook', () => { @@ -174,12 +175,6 @@ describe('hash utilities', () => { expect(stableStringify({ a: growing })).toBe('{"a":[1,2]}'); }); - it('throws on a cycle, as JSON.stringify does', () => { - const cyc: Record = { a: 1 }; - cyc.self = cyc; - expect(() => stableStringify(cyc)).toThrow(TypeError); - }); - it('serializes a sparse array hole as null, as JSON.stringify does', () => { const sparse: unknown[] = []; sparse[2] = 1; diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index 9959a0c..cdd04d1 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -28,6 +28,15 @@ describe('FormoAnalyticsSession', () => { session = new FormoAnalyticsSession(); }); + afterEach(() => { + jest.useRealTimers(); + }); + + /** What a fresh session finds in storage, by key. */ + const givenStored = (values: Record) => { + mockStorage.get.mockImplementation((key: string) => values[key] ?? null); + }; + describe('wallet detection', () => { it('should return false for undetected wallet', () => { expect(session.isWalletDetected('io.metamask')).toBe(false); @@ -201,10 +210,6 @@ describe('FormoAnalyticsSession', () => { describe('expiry', () => { const DAY = 24 * 60 * 60 * 1000; - afterEach(() => { - jest.useRealTimers(); - }); - it('renews the day on every write, so a later marker gets a full day', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); session.markWalletDetected('io.metamask'); @@ -267,11 +272,9 @@ describe('FormoAnalyticsSession', () => { }); it('drops stale markers found in storage on load', () => { - const stale = String(Date.now() - DAY - 1000); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected_at') return stale; - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - return null; + givenStored({ + wallet_detected_at: String(Date.now() - DAY - 1000), + wallet_detected: JSON.stringify(['io.metamask']), }); const fresh = new FormoAnalyticsSession(); @@ -281,14 +284,11 @@ describe('FormoAnalyticsSession', () => { }); it('drops only the stale set on load', () => { - const stale = String(Date.now() - DAY - 1000); - const recent = String(Date.now() - 1000); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected_at') return stale; - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - if (key === 'wallet_identified_at') return recent; - if (key === 'wallet_identified') return JSON.stringify(['0x123:io.metamask::']); - return null; + givenStored({ + wallet_detected_at: String(Date.now() - DAY - 1000), + wallet_detected: JSON.stringify(['io.metamask']), + wallet_identified_at: String(Date.now() - 1000), + wallet_identified: JSON.stringify(['0x123:io.metamask::']), }); const fresh = new FormoAnalyticsSession(); @@ -298,29 +298,26 @@ describe('FormoAnalyticsSession', () => { }); it('keeps the shared legacy timestamp, so markers already past their day expire now', () => { - const DAY = 24 * 60 * 60 * 1000; jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - if (key === 'wallet_marked_at') return String(Date.now() - DAY - 1000); // shared, stale - return null; + givenStored({ + wallet_detected: JSON.stringify(['io.metamask']), + wallet_marked_at: String(Date.now() - DAY - 1000), // shared, stale }); const upgraded = new FormoAnalyticsSession(); expect(upgraded.isWalletDetected('io.metamask')).toBe(false); expect(mockStorage.remove).toHaveBeenCalledWith('wallet_marked_at'); - jest.useRealTimers(); }); it('stamps markers written by a version without per-set timestamps, so they expire too', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); const shared = Date.now() - 1000; - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - if (key === 'wallet_identified') return JSON.stringify(['0x123:io.metamask']); - if (key === 'wallet_marked_at') return String(shared); - return null; // no per-set timestamp: written before it existed + // No per-set timestamps: written before they existed. + givenStored({ + wallet_detected: JSON.stringify(['io.metamask']), + wallet_identified: JSON.stringify(['0x123:io.metamask']), + wallet_marked_at: String(shared), }); const upgraded = new FormoAnalyticsSession(); @@ -337,11 +334,9 @@ describe('FormoAnalyticsSession', () => { }); it('keeps markers found in storage that are still within the day', () => { - const recent = String(Date.now() - 1000); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected_at') return recent; - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - return null; + givenStored({ + wallet_detected_at: String(Date.now() - 1000), + wallet_detected: JSON.stringify(['io.metamask']), }); const fresh = new FormoAnalyticsSession(); @@ -352,11 +347,9 @@ describe('FormoAnalyticsSession', () => { it('clamps a future timestamp to now on load', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); // Written while the clock was set to 2027, then the clock was corrected. - const future = String(new Date('2027-01-01T00:00:00Z').getTime()); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected_at') return future; - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - return null; + givenStored({ + wallet_detected_at: String(new Date('2027-01-01T00:00:00Z').getTime()), + wallet_detected: JSON.stringify(['io.metamask']), }); const fresh = new FormoAnalyticsSession(); @@ -382,10 +375,9 @@ describe('FormoAnalyticsSession', () => { it('treats a non-finite timestamp as missing and stamps now', () => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected_at') return 'not-a-number'; - if (key === 'wallet_detected') return JSON.stringify(['io.metamask']); - return null; + givenStored({ + wallet_detected_at: 'not-a-number', + wallet_detected: JSON.stringify(['io.metamask']), }); const fresh = new FormoAnalyticsSession(); @@ -396,10 +388,6 @@ describe('FormoAnalyticsSession', () => { }); describe('clearIdentified()', () => { - afterEach(() => { - jest.useRealTimers(); - }); - it('forgets identified wallets but keeps detected ones', () => { session.markWalletDetected('io.metamask'); session.markWalletIdentified('0x123', 'io.metamask'); @@ -430,12 +418,7 @@ describe('FormoAnalyticsSession', () => { describe('loading from storage', () => { it('should load detected wallets from storage', () => { - mockStorage.get.mockImplementation((key: string) => { - if (key.includes('detected')) { - return JSON.stringify(['io.metamask', 'com.coinbase.wallet']); - } - return null; - }); + givenStored({ wallet_detected: JSON.stringify(['io.metamask', 'com.coinbase.wallet']) }); const loadedSession = new FormoAnalyticsSession(); @@ -444,11 +427,8 @@ describe('FormoAnalyticsSession', () => { }); it('should load identified wallets from storage', () => { - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_identified') { - return JSON.stringify(['0x123:io.metamask::', '0x456:io.metamask:user-a:']); - } - return null; + givenStored({ + wallet_identified: JSON.stringify(['0x123:io.metamask::', '0x456:io.metamask:user-a:']), }); const loadedSession = new FormoAnalyticsSession(); @@ -458,12 +438,7 @@ describe('FormoAnalyticsSession', () => { }); it('still matches keys written before the user id joined them', () => { - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_identified') { - return JSON.stringify(['0x123:io.metamask']); - } - return null; - }); + givenStored({ wallet_identified: JSON.stringify(['0x123:io.metamask']) }); const loadedSession = new FormoAnalyticsSession(); @@ -473,10 +448,7 @@ describe('FormoAnalyticsSession', () => { it('keeps only the newest 20 entries of an oversized set', () => { const many = Array.from({ length: 25 }, (_, i) => `wallet.${i}`); - mockStorage.get.mockImplementation((key: string) => { - if (key === 'wallet_detected') return JSON.stringify(many); - return null; - }); + givenStored({ wallet_detected: JSON.stringify(many) }); const loadedSession = new FormoAnalyticsSession(); diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 09483ea..f6ac750 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -27,6 +27,8 @@ type MarkerSet = { atKey: string; entries: Set; at: number; + /** Rewrites an entry written by an earlier version, on load. */ + migrate?: (entry: string) => string; }; /** @@ -69,6 +71,8 @@ export class FormoAnalyticsSession { atKey: SESSION_WALLET_IDENTIFIED_AT_KEY, entries: new Set(), at: 0, + // An identified key written before user id and properties joined it. + migrate: (entry) => (entry.split(":").length === 2 ? `${entry}::` : entry), }; constructor() { @@ -80,9 +84,7 @@ export class FormoAnalyticsSession { */ private loadFromStorage(): void { try { - // A version that shared one expiry between both sets: its timestamp - // seeds each set that has none, so markers already past their day - // still expire now rather than a day from now. + // A version that shared one expiry between both sets. const legacyAtRaw = storage().get(LEGACY_WALLET_MARKED_AT_KEY); const legacyAt = legacyAtRaw ? parseInt(legacyAtRaw, 10) : 0; if (legacyAtRaw) storage().remove(LEGACY_WALLET_MARKED_AT_KEY); @@ -97,9 +99,8 @@ export class FormoAnalyticsSession { const raw = storage().get(set.key); if (!raw) return; const parsed = JSON.parse(raw) as string[]; - set.entries = new Set( - parsed.slice(-MAX_MARKER_ENTRIES).map((entry) => this.migrateEntry(set, entry)) - ); + const kept = parsed.slice(-MAX_MARKER_ENTRIES); + set.entries = new Set(set.migrate ? kept.map(set.migrate) : kept); if (set.entries.size === 0) return; // A truncated or migrated set is written back, or every cold start // would load the legacy value again. @@ -112,33 +113,17 @@ export class FormoAnalyticsSession { // Markers written by a version without a per-set timestamp keep the // shared one if there was one, else get their day from now; either // way they expire. - set.at = Number.isFinite(legacyAt) && legacyAt > 0 ? Math.min(legacyAt, Date.now()) : Date.now(); + set.at = legacyAt > 0 ? Math.min(legacyAt, Date.now()) : Date.now(); storage().set(set.atKey, String(set.at)); } this.expireIfStale(set); } - /** An identified key written before user id and properties joined it. */ - private migrateEntry(set: MarkerSet, entry: string): string { - if (set !== this.identified) return entry; - return entry.split(":").length === 2 ? `${entry}::` : entry; - } - - /** - * The stored timestamp, or 0 when it is missing or unusable. A future - * timestamp is clamped to now: a clock set forward and then corrected - * must not keep a marker alive past its day. - */ + /** The stored timestamp, or 0 when it is missing or unusable. */ private readTimestamp(set: MarkerSet): number { const raw = storage().get(set.atKey); const parsed = raw ? parseInt(raw, 10) : 0; - if (!Number.isFinite(parsed) || parsed <= 0) return 0; - const now = Date.now(); - if (parsed > now) { - storage().set(set.atKey, String(now)); - return now; - } - return parsed; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } private saveSet(set: MarkerSet): void { @@ -152,7 +137,22 @@ export class FormoAnalyticsSession { } } - private addEntry(set: MarkerSet, entry: string): void { + private hasEntry(set: MarkerSet, entry: string): boolean { + this.expireIfStale(set); + return set.entries.has(entry); + } + + /** + * Add an entry unless it is already there. With `supersede`, the entries + * that start with it go first: the new entry replaces their state. + */ + private markEntry(set: MarkerSet, entry: string, supersede?: string): void { + if (this.hasEntry(set, entry)) return; + if (supersede !== undefined) { + for (const old of set.entries) { + if (old.startsWith(supersede)) set.entries.delete(old); + } + } set.entries.add(entry); for (const oldest of set.entries) { if (set.entries.size <= MAX_MARKER_ENTRIES) break; @@ -161,7 +161,11 @@ export class FormoAnalyticsSession { this.saveSet(set); } - /** Drop the set once a day has passed since its last write. */ + /** + * Drop the set once a day has passed since its last write. A future + * timestamp is clamped to now: a clock set forward and then corrected + * must not keep a marker alive past its day. + */ private expireIfStale(set: MarkerSet): void { if (!set.at) return; const now = Date.now(); @@ -186,17 +190,14 @@ export class FormoAnalyticsSession { * Check if a wallet has been detected in this session */ public isWalletDetected(rdns: string): boolean { - this.expireIfStale(this.detected); - return this.detected.entries.has(rdns); + return this.hasEntry(this.detected, rdns); } /** * Mark a wallet as detected */ public markWalletDetected(rdns: string): void { - this.expireIfStale(this.detected); - if (this.detected.entries.has(rdns)) return; - this.addEntry(this.detected, rdns); + this.markEntry(this.detected, rdns); } /** @@ -229,9 +230,8 @@ export class FormoAnalyticsSession { userId?: string, properties?: IFormoEventProperties ): boolean { - this.expireIfStale(this.identified); const { key } = this.buildIdentificationKey(address, rdns, userId, properties); - return this.identified.entries.has(key); + return this.hasEntry(this.identified, key); } /** @@ -243,22 +243,10 @@ export class FormoAnalyticsSession { userId?: string, properties?: IFormoEventProperties ): void { - this.expireIfStale(this.identified); - const { key, prefix } = this.buildIdentificationKey( - address, - rdns, - userId, - properties - ); - if (this.identified.entries.has(key)) return; + const { key, prefix } = this.buildIdentificationKey(address, rdns, userId, properties); // Keep only the wallet-user's latest state. Otherwise a profile that // reverts to an earlier value would match the stale entry and send nothing. - for (const entry of this.identified.entries) { - if (entry.startsWith(`${prefix}:`)) { - this.identified.entries.delete(entry); - } - } - this.addEntry(this.identified, key); + this.markEntry(this.identified, key, `${prefix}:`); } /** diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 07e8880..46d7db1 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -23,8 +23,7 @@ export function hash(input: string): string { * cycle throws. Returns undefined where JSON.stringify would. */ export function stableStringify(value: unknown): string | undefined { - const out = canonical(value, "", new Set(), false); - return out === undefined ? undefined : JSON.stringify(out); + return JSON.stringify(canonical(value, "", new Set(), false)); } /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ From 0c5f2787f6e269b884050caa64b50159c899dfe7 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 16:05:18 +0700 Subject: [PATCH 14/17] refactor: the serializer matches the web SDK byte for byte Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/utils/hash.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 46d7db1..20a0f20 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -29,7 +29,8 @@ export function stableStringify(value: unknown): string | undefined { /** A sorted, cycle-checked clone that JSON.stringify serializes as-is. */ function canonical(value: unknown, key: string, stack: Set, fromToJSON: boolean): unknown { // Objects, functions and bigints can carry a toJSON hook, as in JSON.stringify. - const hookable = value !== null && (typeof value === "object" || typeof value === "function" || typeof value === "bigint"); + const t = typeof value; + const hookable = value !== null && (t === "object" || t === "function" || t === "bigint"); if (!hookable) return value; // toJSON() runs once per property, as in JSON.stringify: what it returns // is serialized as-is, its own hook included, and a hook that returns its @@ -38,8 +39,7 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: if (!fromToJSON) { const hook = (value as { toJSON?: unknown }).toJSON; if (typeof hook === "function") { - const out: unknown = Reflect.apply(hook, value, [key]); - if (out !== value) return canonical(out, key, stack, true); + return canonical(Reflect.apply(hook, value, [key]), key, stack, true); } } // A bigint without a hook is left to JSON.stringify, which throws on it. @@ -49,7 +49,7 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: // Unboxed through the built-in methods, not an override on the instance. // Guarded: a runtime without BigInt must not throw here on every object. if (typeof BigInt !== "undefined" && value instanceof BigInt) { - return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it, as before + return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it } if (value instanceof Number) return Number.prototype.valueOf.call(value); if (value instanceof String) return String.prototype.valueOf.call(value); @@ -65,13 +65,8 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: } const record = value as Record; const sorted: Record = Object.create(null); - for (const k of Object.keys(record).sort()) { - const v = record[k]; - if (v === undefined || typeof v === "symbol") continue; - const c = canonical(v, k, stack, false); - // Omitted values (functions without a hook) never reach the clone. - if (c !== undefined) sorted[k] = c; - } + // JSON.stringify omits undefined and symbol values itself. + for (const k of Object.keys(record).sort()) sorted[k] = canonical(record[k], k, stack, false); return sorted; } finally { stack.delete(value); From 780657617f573cbfa08bcacad686ac67c3a0170d Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 16:18:04 +0700 Subject: [PATCH 15/17] fix: identify() sends the effective user id it dedups on Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/FormoAnalytics.ts | 2 +- src/__tests__/FormoAnalytics.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 7a4a2c7..ebf72cd 100644 --- a/src/FormoAnalytics.ts +++ b/src/FormoAnalytics.ts @@ -744,7 +744,7 @@ export class FormoAnalytics implements IFormoAnalytics { await this.trackEvent( EventType.IDENTIFY, - { address: validAddress, providerName, userId, rdns }, + { address: validAddress, providerName, userId: this.currentUserId, rdns }, properties, context, callback diff --git a/src/__tests__/FormoAnalytics.test.ts b/src/__tests__/FormoAnalytics.test.ts index 9886999..ce3c1ed 100644 --- a/src/__tests__/FormoAnalytics.test.ts +++ b/src/__tests__/FormoAnalytics.test.ts @@ -543,6 +543,19 @@ describe('FormoAnalytics', () => { expect(mockEventManager.addEvent).not.toHaveBeenCalled(); }); + it('identify() without a user id carries the effective user on the wire, as the marker does', async () => { + analytics.currentUserId = 'restored-user'; + mockEventManager.addEvent.mockClear(); + + await analytics.identify({ address: '0x51377e9B985Bb90B7c091B9a7d30C93d4c9c1CEf', rdns: 'io.metamask' }); + + const event = mockEventManager.addEvent.mock.calls.at(-1)?.[0]; + expect(event.userId).toBe('restored-user'); + expect(mockSession.markWalletIdentified).toHaveBeenCalledWith( + '0x51377e9B985Bb90B7c091B9a7d30C93d4c9c1CEf', 'io.metamask', 'restored-user', undefined + ); + }); + it('treats a null or undefined idempotency_key as no key and strips the property', async () => { for (const idempotency_key of [undefined, null]) { mockEventManager.addEvent.mockClear(); From a6219f8dbda9014b608f923e471af418d6180ae5 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 16:35:50 +0700 Subject: [PATCH 16/17] fix: a bigint returned by a toJSON hook throws, as JSON.stringify does Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 10 ++++++++++ src/utils/hash.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index a543b25..0f33511 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -113,6 +113,16 @@ describe('hash utilities', () => { }); describe('stableStringify()', () => { + it('throws for a bigint returned by a toJSON hook, as JSON.stringify does', () => { + const proto = BigInt.prototype as unknown as { toJSON?: () => string }; + proto.toJSON = function () { return this.toString(); }; + try { + expect(() => stableStringify({ p: { toJSON: () => BigInt(1) } })).toThrow(TypeError); + } finally { + delete proto.toJSON; + } + }); + it('unboxes primitive wrappers, as JSON.stringify does', () => { const a = { amount: new Number(1), ok: new Boolean(true), s: new String('x') }; expect(stableStringify(a)).toBe(JSON.stringify(a)); diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 20a0f20..8945cb5 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -42,8 +42,12 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: return canonical(Reflect.apply(hook, value, [key]), key, stack, true); } } - // A bigint without a hook is left to JSON.stringify, which throws on it. - if (typeof value === "bigint") return value; + // A bigint reached through a hook throws as JSON.stringify does; one with + // no hook of its own is left to JSON.stringify, which throws on it. + if (typeof value === "bigint") { + if (fromToJSON) throw new TypeError("Do not know how to serialize a BigInt"); + return value; + } // A function with no hook is omitted, as JSON.stringify omits it. if (typeof value === "function") return undefined; // Unboxed through the built-in methods, not an override on the instance. From c9e0193f6c8595390ac08cac637af0e7d7204463 Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 9 Sep 2026 16:58:54 +0700 Subject: [PATCH 17/17] fix: a boxed bigint returned by a toJSON hook throws, as JSON.stringify does Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Afmyxxipx1ATADBWi2bFGZ --- src/__tests__/hash.test.ts | 1 + src/utils/hash.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/__tests__/hash.test.ts b/src/__tests__/hash.test.ts index 0f33511..0a44f8a 100644 --- a/src/__tests__/hash.test.ts +++ b/src/__tests__/hash.test.ts @@ -118,6 +118,7 @@ describe('hash utilities', () => { proto.toJSON = function () { return this.toString(); }; try { expect(() => stableStringify({ p: { toJSON: () => BigInt(1) } })).toThrow(TypeError); + expect(() => stableStringify({ p: { toJSON: () => Object(BigInt(1)) } })).toThrow(TypeError); } finally { delete proto.toJSON; } diff --git a/src/utils/hash.ts b/src/utils/hash.ts index 8945cb5..8c81759 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -53,6 +53,7 @@ function canonical(value: unknown, key: string, stack: Set, fromToJSON: // Unboxed through the built-in methods, not an override on the instance. // Guarded: a runtime without BigInt must not throw here on every object. if (typeof BigInt !== "undefined" && value instanceof BigInt) { + if (fromToJSON) throw new TypeError("Do not know how to serialize a BigInt"); return BigInt.prototype.valueOf.call(value); // JSON.stringify throws on it } if (value instanceof Number) return Number.prototype.valueOf.call(value);