diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index d6300a37..f7e5fc21 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -213,7 +213,7 @@ function ensureDecryptCandidates(rawCandidates) { .filter(Boolean); } -function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { +function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLegacy = false } = {}) { if (!candidates.length) { return []; } @@ -250,14 +250,14 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { }; return [...candidates].sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority; - } - - const aRank = desiredSchemeRank(a.scheme); - const bRank = desiredSchemeRank(b.scheme); - if (aRank !== bRank) { - return aRank - bRank; + // Legacy (kind 4) DMs are always NIP-04 — try nip04 candidates first so we + // don't burn a (serialized, slow) extension round-trip on nip44 first. + if (preferLegacy) { + const aL = a.scheme === "nip04" ? 0 : 1; + const bL = b.scheme === "nip04" ? 0 : 1; + if (aL !== bL) { + return aL - bL; + } } if (preferGiftWrap) { @@ -268,6 +268,16 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) { } } + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + + const aRank = desiredSchemeRank(a.scheme); + const bRank = desiredSchemeRank(b.scheme); + if (aRank !== bRank) { + return aRank - bRank; + } + return 0; }); } @@ -538,7 +548,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { }); } - const ordered = orderDecryptors(decryptors, hints); + const ordered = orderDecryptors(decryptors, hints, { preferLegacy: true }); const errors = []; const remoteCandidates = []; @@ -604,28 +614,29 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { } }; - const attempts = []; + // Try candidates sequentially and STOP at the first success. Racing every + // (decryptor x remote) combination in parallel looked fast, but the nip-07 + // extension serializes calls — so racing N combinations just queued N slow + // extension round-trips per message (the legacy DM history was ~12 extension + // calls per message). With nip04 ordered first and the remote party first, the + // first attempt normally succeeds, so this is ~1 call per message with no + // worse latency-to-first-success on the serialized extension. for (const decryptor of ordered) { for (const remotePubkey of remoteCandidates) { - attempts.push(attemptDecryption(decryptor, remotePubkey)); + try { + return await attemptDecryption(decryptor, remotePubkey); + } catch (failure) { + errors.push(failure); + } } } - // Race all decryption attempts to return the first successful result - // This significantly reduces latency compared to sequential attempts - try { - return await raceOrdered(attempts); - } catch (aggregateError) { - const aggregatedErrors = aggregateError.errors || []; - errors.push(...aggregatedErrors); - - return buildDecryptResult({ - ok: false, - event, - actorPubkey, - errors, - }); - } + return buildDecryptResult({ + ok: false, + event, + actorPubkey, + errors, + }); } export async function decryptDM(event, context = {}) { diff --git a/scripts/perf/dm-repro.mjs b/scripts/perf/dm-repro.mjs new file mode 100644 index 00000000..f2380bb5 --- /dev/null +++ b/scripts/perf/dm-repro.mjs @@ -0,0 +1,134 @@ +// Focused reproduction of DM history, using the fake nip-07 extension. +// +// Seeds legacy (kind 4 / NIP-04) DMs between the user and a peer on a mock relay, +// logs in via the fake extension, then calls nostrClient.listDirectMessages and +// reports what comes back (decrypted messages, errors, extension call counts) — +// so we can see whether DM history is broken vs slow, and why. +// +// Usage: node scripts/perf/dm-repro.mjs (env: DM_MSGS=20 LATENCY_MS=0) + +import { chromium } from "playwright"; +import { startRelay } from "../agent/simple-relay.mjs"; +import { finalizeEvent, getPublicKey, generateSecretKey, nip04 } from "nostr-tools"; +import * as nip44 from "nostr-tools/nip44"; + +const APP = process.env.APP || "http://localhost:3000"; +const LATENCY_MS = Number(process.env.LATENCY_MS ?? 0); +const DM_MSGS = Number(process.env.DM_MSGS ?? 20); +const WS_PORT = 8962; + +const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex")); +const USER_PK = getPublicKey(USER_SK); +const PEER_SK = generateSecretKey(); +const PEER_PK = getPublicKey(PEER_SK); + +let extCalls = {}; +let queue = Promise.resolve(); +function viaExtension(method, fn) { + extCalls[method] = (extCalls[method] || 0) + 1; + const run = async () => { + if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS)); + return fn(); + }; + const p = queue.then(run, run); + queue = p.catch(() => {}); + return p; +} +const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk); + +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)); + + // Seed kind-4 DMs: peer -> user (received) and user -> peer (sent). + for (let i = 0; i < DM_MSGS; i++) { + const fromPeer = i % 2 === 0; + const sk = fromPeer ? PEER_SK : USER_SK; + const authorPk = fromPeer ? PEER_PK : USER_PK; + const otherPk = fromPeer ? USER_PK : PEER_PK; + const text = `DM message ${i} (${fromPeer ? "from peer" : "to peer"})`; + const content = nip04.encrypt(sk, otherPk, text); + relay.seedEvent( + finalizeEvent( + { kind: 4, created_at: now - i * 60, tags: [["p", otherPk]], content }, + sk, + ), + ); + } + console.log(`Seeded ${DM_MSGS} kind-4 DMs. user=${USER_PK.slice(0, 10)} peer=${PEER_PK.slice(0, 10)}`); + + const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" }); + const context = await browser.newContext(); + await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK)); + await context.exposeBinding("__extSignEvent", (_s, e) => viaExtension("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK))); + await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t))); + await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct))); + await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk)))); + await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk)))); + 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); + window.nostr = { + getPublicKey: () => w("__extGetPublicKey")(), + signEvent: (e) => w("__extSignEvent")(e), + getRelays: async () => ({}), + nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) }, + nip44: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c), v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) } }, + }; + }, relayUrl); + + const page = await context.newPage(); + const logs = []; + page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 300))); + page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`)); + + await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {}); + await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); + + // Login via fake extension. + try { + await page.click('[data-testid="login-button"]', { timeout: 8000 }); + await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 }); + const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first(); + if (await extBtn.count()) await extBtn.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(() => {}); + + // Probe: list DMs directly via the client. + const result = await page.evaluate(async (userPk) => { + const c = window.__bitvidTest__?.nostrClient; + if (!c || typeof c.listDirectMessages !== "function") return { ok: false, reason: "no-listDirectMessages" }; + try { + const msgs = await c.listDirectMessages(userPk, { timeoutMs: 8000 }); + return { + ok: true, + count: Array.isArray(msgs) ? msgs.length : 0, + sample: Array.isArray(msgs) ? msgs.slice(0, 3).map((m) => ({ ok: m?.ok, text: (m?.text || m?.content || "").slice(0, 40), from: (m?.pubkey || "").slice(0, 8) })) : null, + }; + } catch (e) { + return { ok: false, reason: String(e).split("\n")[0] }; + } + }, USER_PK); + + console.log("\n================ DM REPRO ================"); + console.log(`listDirectMessages: ${JSON.stringify(result)}`); + console.log(`Extension calls: ${JSON.stringify(extCalls)}`); + const dm = logs.filter((l) => /dm|message|decrypt|permission|nip04|nip44|error|warn|fail|unavailable/i.test(l)).slice(0, 25); + console.log(`\nConsole (DM-related):`); + dm.forEach((l) => console.log(" " + l)); + console.log("=========================================\n"); + + await browser.close(); + if (typeof relay.close === "function") await relay.close(); + process.exit(0); +} + +main().catch((e) => { console.error("dm-repro failed:", e); process.exit(1); });