Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/lib/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { hashPublicKey, trackEvent } from "./analytics";

describe("analytics module", () => {
const addr1 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX";
const addr2 = "GABCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ";
const addr3 = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWY"; // 1 char diff at end

describe("hashPublicKey", () => {
it("returns empty string for invalid or missing inputs", async () => {
expect(await hashPublicKey("")).toBe("");
expect(await hashPublicKey(null as unknown as string)).toBe("");
expect(await hashPublicKey(undefined as unknown as string)).toBe("");
});

it("is deterministic (same input produces identical hash)", async () => {
const hashA = await hashPublicKey(addr1);
const hashB = await hashPublicKey(addr1);
expect(hashA).toBe(hashB);
expect(hashA.length).toBe(16);
});

it("hashes the full input so addresses sharing the same first 4 chars yield different outputs", async () => {
const hash1 = await hashPublicKey(addr1);
const hash2 = await hashPublicKey(addr2);

// Both start with 'GABC', but total strings differ drastically
expect(addr1.slice(0, 4)).toBe("GABC");
expect(addr2.slice(0, 4)).toBe("GABC");
expect(hash1).not.toBe(hash2);
});

it("exhibits avalanche effect for single character variations at the end", async () => {
const hash1 = await hashPublicKey(addr1);
const hash3 = await hashPublicKey(addr3);
expect(hash1).not.toBe(hash3);
});

it("does not leak the raw ASCII byte hex representation of the address prefix", async () => {
const hash = await hashPublicKey(addr1);
// Raw ASCII hex of 'GABC' is '47414243'
expect(hash).not.toContain("47414243");
expect(hash.startsWith("47414243")).toBe(false);
});
});

describe("trackEvent", () => {
const originalWindow = globalThis.window;

beforeEach(() => {
// Setup a window environment with mock gtag and dataLayer
const mockWindow = {
gtag: vi.fn(),
dataLayer: [],
};
vi.stubGlobal("window", mockWindow);
});

afterEach(() => {
vi.stubGlobal("window", originalWindow);
});

it("sanitizes publicKey in props using hashPublicKey before dispatching to gtag", async () => {
await trackEvent("test_event", { publicKey: addr1, amount: 100 });

const w = window as unknown as { gtag: ReturnType<typeof vi.fn> };
expect(w.gtag).toHaveBeenCalledTimes(1);

const [command, eventName, payload] = w.gtag.mock.calls[0];
expect(command).toBe("event");
expect(eventName).toBe("test_event");
expect(payload.amount).toBe(100);
expect(typeof payload.timestamp).toBe("number");

// Verify publicKey is sanitized and no longer contains raw address
expect(payload.publicKey).not.toBe(addr1);
expect(payload.publicKey).toBe(await hashPublicKey(addr1));
});

it("pushes to dataLayer if gtag is not present", async () => {
const dataLayer: Record<string, unknown>[] = [];
vi.stubGlobal("window", { dataLayer });

await trackEvent("data_layer_event", { publicKey: addr2 });

expect(dataLayer.length).toBe(1);
expect(dataLayer[0].event).toBe("data_layer_event");
expect(dataLayer[0].publicKey).toBe(await hashPublicKey(addr2));
});

it("does not throw if window.gtag throws an error", async () => {
vi.stubGlobal("window", {
gtag: () => {
throw new Error("Gtag error");
},
});

await expect(
trackEvent("error_event", { publicKey: addr1 })
).resolves.not.toThrow();
});
});
});
56 changes: 45 additions & 11 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AnalyticsProps = Record<string, unknown>;
export type AnalyticsProps = Record<string, unknown>;

type GtagFn = (command: "event", name: string, props?: AnalyticsProps) => void;

Expand All @@ -7,22 +7,56 @@ type AnalyticsWindow = Window & {
dataLayer?: AnalyticsProps[];
};

function hashPublicKey(publicKey: string): string {
if (typeof window === "undefined" || !publicKey) return "";
const encoder = new TextEncoder();
const data = encoder.encode(publicKey);
return Array.from(new Uint8Array(data.slice(0, 4)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 8);
function fallbackHash(input: string): string {
let h1 = 0x811c9dc5;
let h2 = 0x01000193;
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
h1 = Math.imul(h1 ^ code, 16777619);
h2 = Math.imul(h2 ^ code, 0x01000193);
}
const part1 = (h1 >>> 0).toString(16).padStart(8, "0");
const part2 = (h2 >>> 0).toString(16).padStart(8, "0");
return (part1 + part2).slice(0, 16);
}

/**
* Hashes a public key using SHA-256 over the entire string digest.
* Returns a 16-character hex pseudonymous identifier.
*/
export async function hashPublicKey(publicKey: string): Promise<string> {
if (!publicKey || typeof publicKey !== "string") return "";

const cryptoObj =
typeof window !== "undefined" && window.crypto?.subtle
? window.crypto
: typeof globalThis !== "undefined" && globalThis.crypto?.subtle
? globalThis.crypto
: null;

if (cryptoObj?.subtle) {
try {
const data = new TextEncoder().encode(publicKey);
const hashBuffer = await cryptoObj.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 16);
} catch {
// Fall through to fallback hash if Web Crypto digest fails
}
}

return fallbackHash(publicKey);
}

export function trackEvent(name: string, props: AnalyticsProps = {}): void {
export async function trackEvent(name: string, props: AnalyticsProps = {}): Promise<void> {
if (typeof window === "undefined") return;

const sanitized = { ...props };
if ("publicKey" in sanitized && typeof sanitized.publicKey === "string") {
sanitized.publicKey = hashPublicKey(sanitized.publicKey as string);
sanitized.publicKey = await hashPublicKey(sanitized.publicKey as string);
}

const payload = { ...sanitized, timestamp: Date.now() };
Expand Down
Loading