diff --git a/src/FormoAnalytics.ts b/src/FormoAnalytics.ts index 03f1ba9..ebf72cd 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 visitor or chain"); + return; + } let validAddress: Address | undefined = undefined; if (address) { @@ -723,26 +730,21 @@ export class FormoAnalytics implements IFormoAnalytics { storage().set(SESSION_USER_ID_KEY, userId); } - // Check for duplicate identify - const isAlreadyIdentified = validAddress - ? this.session.isWalletIdentified(validAddress, rdns || "") - : 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 || ""); + 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( EventType.IDENTIFY, - { address: validAddress, providerName, userId, rdns }, + { address: validAddress, providerName, userId: this.currentUserId, rdns }, properties, context, callback @@ -764,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__/FormoAnalytics.test.ts b/src/__tests__/FormoAnalytics.test.ts index 1a3dc26..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(); @@ -815,6 +828,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..0a44f8a 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,124 @@ 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); + expect(() => stableStringify({ p: { toJSON: () => Object(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)); + 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('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 })); + // 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 })); + }); + + 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('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"}'); + expect(reads).toBe(1); + }); + + 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('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('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}' + ); + }); + + 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..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); @@ -51,6 +60,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 +108,82 @@ 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 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); + 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('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'); + + 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' }); + + 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); + }); + + 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,19 +197,19 @@ 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'); }); }); 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'); @@ -128,13 +225,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,15 +250,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') 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(); @@ -160,31 +283,106 @@ describe('FormoAnalyticsSession', () => { expect(mockStorage.remove).toHaveBeenCalledWith('wallet_detected'); }); - it('stamps markers written by a version without the timestamp, so they expire too', () => { + it('drops only the stale set on load', () => { + 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(); + + expect(fresh.isWalletDetected('io.metamask')).toBe(false); + expect(fresh.isWalletIdentified('0x123', 'io.metamask')).toBe(true); + }); + + it('keeps the shared legacy timestamp, so markers already past their day expire now', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-09T00:00:00Z')); + 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'); + }); + + 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')); - 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 + const shared = Date.now() - 1000; + // 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(); - expect(mockStorage.set).toHaveBeenCalledWith('wallet_marked_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); 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') 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(); + + 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. + givenStored({ + wallet_detected_at: String(new Date('2027-01-01T00:00:00Z').getTime()), + wallet_detected: JSON.stringify(['io.metamask']), + }); + + 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')); + givenStored({ + wallet_detected_at: 'not-a-number', + wallet_detected: JSON.stringify(['io.metamask']), }); const fresh = new FormoAnalyticsSession(); + expect(mockStorage.set).toHaveBeenCalledWith('wallet_detected_at', String(Date.now())); expect(fresh.isWalletDetected('io.metamask')).toBe(true); }); }); @@ -198,33 +396,29 @@ 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 }); }); 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(); @@ -233,16 +427,34 @@ describe('FormoAnalyticsSession', () => { }); it('should load identified wallets from storage', () => { - mockStorage.get.mockImplementation((key: string) => { - if (key.includes('identified')) { - return JSON.stringify(['0x123:io.metamask']); - } - return null; + givenStored({ + wallet_identified: JSON.stringify(['0x123:io.metamask::', '0x456:io.metamask:user-a:']), }); 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', () => { + givenStored({ wallet_identified: JSON.stringify(['0x123:io.metamask']) }); + + 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}`); + givenStored({ wallet_detected: JSON.stringify(many) }); + + 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..f6ac750 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -1,28 +1,79 @@ 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; + /** Rewrites an entry written by an earlier version, on load. */ + migrate?: (entry: string) => string; +}; + +/** + * 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, + // An identified key written before user id and properties joined it. + migrate: (entry) => (entry.split(":").length === 2 ? `${entry}::` : entry), + }; constructor() { this.loadFromStorage(); @@ -33,95 +84,169 @@ 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); - } - - const identified = storage().get(SESSION_WALLET_IDENTIFIED_KEY); - if (identified) { - const parsed = JSON.parse(identified) as string[]; - this.identifiedWallets = new Set(parsed); - } - // 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)); - } + // 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); + this.loadSet(this.detected, legacyAt); + this.loadSet(this.identified, legacyAt); } catch (error) { logger.debug("Session: Failed to load from storage", error); } } - /** - * Save session state to storage - */ - private saveToStorage(): void { + private loadSet(set: MarkerSet, legacyAt = 0): void { + const raw = storage().get(set.key); + if (!raw) return; + const parsed = JSON.parse(raw) as string[]; + 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. + 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 + // shared one if there was one, else get their day from now; either + // way they expire. + set.at = legacyAt > 0 ? Math.min(legacyAt, Date.now()) : Date.now(); + storage().set(set.atKey, String(set.at)); + } + this.expireIfStale(set); + } + + /** 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; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + 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 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; + set.entries.delete(oldest); + } + this.saveSet(set); + } + + /** + * 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(); + 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); + return this.hasEntry(this.detected, rdns); } /** * Mark a wallet as detected */ public markWalletDetected(rdns: string): void { - this.detectedWallets.add(rdns); - this.saveToStorage(); + this.markEntry(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-user; marking replaces that wallet-user's earlier state, as on + * web, so dedup means "the same as this wallet-user's last identify". */ - 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 } { + // 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 }; } - /** 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 { + const { key } = this.buildIdentificationKey(address, rdns, userId, properties); + return this.hasEntry(this.identified, 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 { + 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. + this.markEntry(this.identified, key, `${prefix}:`); } /** @@ -129,24 +254,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..8c81759 100644 --- a/src/utils/hash.ts +++ b/src/utils/hash.ts @@ -15,6 +15,69 @@ export function hash(input: string): string { return bytesToHex(hashBytes); } +/** + * 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): string | undefined { + return JSON.stringify(canonical(value, "", new Set(), false)); +} + +/** 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 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 + // 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") { + return canonical(Reflect.apply(hook, value, [key]), key, stack, true); + } + } + // 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. + // 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); + 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[] = []; + 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; + const sorted: Record = Object.create(null); + // 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); + } +} + // 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;