diff --git a/scripts/perf/wh-repro.mjs b/scripts/perf/wh-repro.mjs new file mode 100644 index 00000000..960012ab --- /dev/null +++ b/scripts/perf/wh-repro.mjs @@ -0,0 +1,129 @@ +// Focused watch-history decrypt reproduction with FRESH contexts per session +// (so the persisted decrypt cache doesn't warm between cold/warm runs), and +// page-side capture of where window.nostr.nip44.decrypt is actually called from. +// +// Usage: node scripts/perf/wh-repro.mjs (env: WH_ITEMS=200) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const WH_ITEMS = Number(process.env.WH_ITEMS ?? 200); +const WS_PORT = 8963; +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); + +let calls = 0; +const cts = new Map(); // ciphertext -> count +const convKey = (pk) => nip44.v2.utils.getConversationKey(USER_SK, pk); + +async function setupContext(browser, relayUrl) { + const context = await browser.newContext(); + await context.exposeBinding("__gpk", () => USER_PK); + await context.exposeBinding("__sign", (_s, e) => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK)); + await context.exposeBinding("__n44e", (_s, pk, t) => nip44.v2.encrypt(t, convKey(pk))); + await context.exposeBinding("__n44d", (_s, pk, ct) => { + calls += 1; + cts.set(ct, (cts.get(ct) || 0) + 1); + return nip44.v2.decrypt(ct, convKey(pk)); + }); + await context.exposeBinding("__stack", (_s, s) => { + // log only the first few distinct stacks + if (setupContext._stacks === undefined) setupContext._stacks = new Set(); + if (setupContext._stacks.size < 4) { + setupContext._stacks.add(s); + console.log("STACK>", s); + } + }); + await context.addInitScript((url) => { + localStorage.setItem("hasSeenDisclaimer", "true"); + localStorage.setItem("__bitvidTestMode__", "1"); + localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url])); + localStorage.setItem("bitvid_admin_whitelist_mode", "false"); + const w = (n) => (...a) => window[n](...a); + const n44d = (p, c) => { + try { window.__stack((new Error().stack || "").split("\n").slice(1, 5).join(" <- ")); } catch (_) {} + return w("__n44d")(p, c); + }; + window.nostr = { + getPublicKey: () => w("__gpk")(), + signEvent: (e) => w("__sign")(e), + getRelays: async () => ({}), + nip04: { encrypt: async () => "", decrypt: async () => "" }, + nip44: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d, v2: { encrypt: (p, t) => w("__n44e")(p, t), decrypt: n44d } }, + }; + }, relayUrl); + return context; +} + +async function login(context, relayUrl) { + const page = await context.newPage(); + await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((u) => window.__bitvidTest__?.setTestRelays?.([u], { persist: false }), relayUrl).catch(() => {}); + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const b = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await b.count()) await b.click({ timeout: 5000 }); + else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 }); + } catch (_) {} + await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {}); + return page; +} + +async function main() { + const relay = startRelay(WS_PORT, { httpPort: false }); + const relayUrl = `ws://127.0.0.1:${WS_PORT}`; + const now = Math.floor(Date.now() / 1000); + relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK)); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + + // Context A: publish a watch history of WH_ITEMS pointers. + const ctxA = await setupContext(browser, relayUrl); + const pA = await login(ctxA, relayUrl); + const hex = (i) => (i.toString(16).padStart(8, "0") + "a".repeat(56)).slice(0, 64); + const MONTHS = Number(process.env.MONTHS ?? 1); + // Spread items across MONTHS distinct months to exercise the monthly bucketing. + const items = Array.from({ length: WH_ITEMS }, (_, i) => ({ + type: "e", + value: hex(i + 1), + watchedAt: now - (i % MONTHS) * 31 * 86400 - i * 30, + })); + const pub = await pA.evaluate(async (its) => { + const c = window.__bitvidTest__?.nostrClient; + try { return { ok: true, r: await c.publishWatchHistorySnapshot(its, {}) }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, items); + await pA.waitForTimeout(1500); + const byKind = {}; + for (const e of relay.getEvents().values()) byKind[e.kind] = (byKind[e.kind] || 0) + 1; + await ctxA.close(); + + // Context B (FRESH = truly cold): resolve watch history, count decrypts. + calls = 0; cts.clear(); + const ctxB = await setupContext(browser, relayUrl); + const pB = await login(ctxB, relayUrl); + const cold = await pB.evaluate(async (pk) => { + const c = window.__bitvidTest__?.nostrClient; + try { const it = await c.resolveWatchHistory(pk, { forceRefresh: true }); return { ok: true, items: it?.length || 0 }; } catch (e) { return { ok: false, e: String(e).split("\n")[0] }; } + }, USER_PK); + await pB.waitForTimeout(1500); + const coldCalls = calls, coldUnique = cts.size, coldMax = Math.max(0, ...[...cts.values()]); + await ctxB.close(); + + console.log("\n================ WH REPRO ================"); + console.log(`publish: ${JSON.stringify(pub)} (${WH_ITEMS} items)`); + console.log(`relay events by kind: ${JSON.stringify(byKind)}`); + console.log(`COLD resolve: ${JSON.stringify(cold)}`); + console.log(`COLD nip44.decrypt calls=${coldCalls} uniqueCiphertexts=${coldUnique} maxRepeat=${coldMax}`); + console.log("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("wh-repro failed:", e); process.exit(1); });