From 8171fcb347759361e733d8ac113cb3ceb5f51957 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:55:07 -0400 Subject: [PATCH] perf(dm): persist decrypted DM results so reloads skip the extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the persisted-plaintext-decrypt-cache pattern (already used by watch history) into a shared factory, persistedPlaintextCache.js, and adds a DM cache on top of it. watchHistoryDecryptCache now delegates to the factory (same public API; tests unchanged). decryptDirectMessageEvent now also consults/writes a persisted cache keyed by the immutable DM event id (JSON of the decrypt result; flushed once per listDirectMessages batch; cleared on logout alongside the in-memory cache). DM events never change, so a reload re-decrypts only messages it has not seen and skips the (serialized, slow) nip-07 extension for the rest. Harness (dm-repro, now cold/warm): 20 DMs -> COLD nip04.decrypt 50, WARM 0 — reloads cost zero extension decrypt calls. Also excludes the actor's own key from NIP-04 remote candidates (decrypting against self is always wasted). DM unit + integration and watch-history cache tests green. Co-Authored-By: Claude Opus 4.8 --- js/dmDecryptor.js | 6 ++ js/nostr/client.js | 30 ++++++++ js/nostr/persistedPlaintextCache.js | 97 +++++++++++++++++++++++++ js/nostr/watchHistoryDecryptCache.js | 101 +++------------------------ scripts/perf/dm-repro.mjs | 79 +++++++++++---------- 5 files changed, 184 insertions(+), 129 deletions(-) create mode 100644 js/nostr/persistedPlaintextCache.js diff --git a/js/dmDecryptor.js b/js/dmDecryptor.js index f7e5fc21..eff0b928 100644 --- a/js/dmDecryptor.js +++ b/js/dmDecryptor.js @@ -558,6 +558,12 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) { if (!normalized || seenRemotes.has(normalized)) { return; } + // Skip the actor's own key: a NIP-04 conversation key is derived with the + // *other* party, so decrypting against self is always a wasted (slow, + // serialized) extension round-trip. + if (normalizedActor && normalized === normalizedActor) { + return; + } seenRemotes.add(normalized); remoteCandidates.push(normalized); }; diff --git a/js/nostr/client.js b/js/nostr/client.js index 05071b75..39584cc5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -42,6 +42,7 @@ import { resolveStoragePointerValue, } from "../utils/storagePointer.js"; import { RelayBatchFetcher } from "./relayBatchFetcher.js"; +import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js"; import { createWatchHistoryManager, normalizePointerInput, @@ -349,6 +350,13 @@ function cloneEventForCache(event) { const DM_DECRYPT_CACHE_LIMIT = 256; const DM_EVENT_KINDS = Object.freeze([4, 1059]); +// Persisted (across reloads) cache of decrypted DM results keyed by the immutable +// DM event id. DM events never change, so caching the decrypted result means a +// reload re-decrypts only messages we haven't seen — skipping the (serialized, +// slow) nip-07 extension for the rest. Stored as JSON of the decrypt result; +// cleared on logout via clearDmDecryptCache(). +const dmPlaintextCache = createPersistedPlaintextCache("bitvid:dmDecrypted:v1", 1000); + function buildDmFilters(actorPubkey, { since, until, limit } = {}) { const normalizedActor = normalizeActorKey(actorPubkey); const filters = []; @@ -1140,6 +1148,7 @@ export class NostrClient { if (this.dmDecryptCache) { this.dmDecryptCache.clear(); } + dmPlaintextCache.clear(); } async ensureDmDecryptor() { @@ -1350,6 +1359,20 @@ export class NostrClient { if (cached) { return cached; } + // Persisted result from a prior session — avoids re-hitting the extension + // to decrypt the same immutable DM on every reload. + const persisted = dmPlaintextCache.get(eventId); + if (persisted) { + try { + const parsed = JSON.parse(persisted); + if (parsed && parsed.ok) { + this.dmDecryptCache.set(eventId, parsed); + return parsed; + } + } catch (_) { + // fall through to a fresh decrypt + } + } } const decryptDM = await this.ensureDmDecryptor(); @@ -1368,6 +1391,11 @@ export class NostrClient { if (result?.ok && eventId) { this.dmDecryptCache.set(eventId, result); + try { + dmPlaintextCache.set(eventId, JSON.stringify(result)); + } catch (_) { + // result not serializable — skip persisting + } } return result; @@ -1436,6 +1464,8 @@ export class NostrClient { settled = true; if (timeoutId) clearTimeout(timeoutId); sub.unsub(); + // Persist any newly-decrypted DM results once, after the batch. + dmPlaintextCache.flush(); messages.sort((a, b) => (b?.timestamp || 0) - (a?.timestamp || 0)); resolve(messages); }; diff --git a/js/nostr/persistedPlaintextCache.js b/js/nostr/persistedPlaintextCache.js new file mode 100644 index 00000000..c4086cba --- /dev/null +++ b/js/nostr/persistedPlaintextCache.js @@ -0,0 +1,97 @@ +// Shared factory for a persisted "decrypted plaintext" cache keyed by event id. +// +// Several nostr surfaces (watch-history chunks, direct messages) decrypt many +// immutable events, each of which is a serialized round-trip to the nip-07 +// extension (the private key lives in the extension and cannot be cached +// locally). Caching the decrypted plaintext by event id means reloads decrypt +// only events we haven't seen before, skipping the extension for the rest — +// across sessions. +// +// The cached plaintext is no more sensitive than the data each surface already +// persists (watch-history items; DM previews). Callers must clear the cache on +// logout so a decrypted secret never outlives its session. +// +// @param {string} storageKey localStorage key for this cache. +// @param {number} [maxEntries] cap on retained entries (most-recent kept). + +export function createPersistedPlaintextCache(storageKey, maxEntries = 400) { + /** @type {Map | null} */ + let cache = null; + let dirty = false; + + function ensure() { + if (cache) { + return cache; + } + cache = new Map(); + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + cache = new Map(parsed); + } + } + } + } catch (_) { + cache = new Map(); + } + return cache; + } + + function get(eventId) { + if (!eventId) { + return null; + } + const c = ensure(); + return c.has(eventId) ? c.get(eventId) : null; + } + + function set(eventId, plaintext) { + if (!eventId || typeof plaintext !== "string" || !plaintext) { + return; + } + const c = ensure(); + if (c.get(eventId) === plaintext) { + return; + } + c.set(eventId, plaintext); + dirty = true; + } + + // Persist pending writes once (call after a batch of decrypts). Bounds size. + function flush() { + if (!dirty) { + return; + } + dirty = false; + try { + if (typeof localStorage === "undefined") { + return; + } + let entries = Array.from(cache.entries()); + if (entries.length > maxEntries) { + entries = entries.slice(entries.length - maxEntries); + cache = new Map(entries); + } + localStorage.setItem(storageKey, JSON.stringify(entries)); + } catch (_) { + // ignore persistence failures (quota/availability) + } + } + + function clear() { + cache = new Map(); + dirty = false; + try { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(storageKey); + } + } catch (_) { + // ignore + } + } + + return { get, set, flush, clear }; +} diff --git a/js/nostr/watchHistoryDecryptCache.js b/js/nostr/watchHistoryDecryptCache.js index 0c8b450a..f018157d 100644 --- a/js/nostr/watchHistoryDecryptCache.js +++ b/js/nostr/watchHistoryDecryptCache.js @@ -1,95 +1,14 @@ // Persisted cache of decrypted watch-history chunk plaintext, keyed by the -// immutable chunk event id. -// -// Watch history is stored as ~150 separately encrypted chunk events. Decrypting -// each one means a round-trip to the nip-07 extension (the private key lives in -// the extension and cannot be cached locally), and the extension serializes -// these — so a full re-decrypt costs many seconds on EVERY load. Chunk events -// are addressable/replaceable: an unchanged chunk keeps its event id, while a -// modified chunk republishes under a new id. Caching plaintext by event id means -// reloads decrypt only the chunk(s) that actually changed (typically one), -// skipping the extension for the rest. -// -// The decrypted plaintext is no more sensitive than the watch-history `items` -// already persisted by the manager. Cleared on logout. +// immutable chunk event id. Backed by the shared persisted-plaintext-cache +// factory (see persistedPlaintextCache.js). An unchanged chunk keeps its event +// id, so reloads decrypt only the chunk(s) that actually changed and skip the +// nip-07 extension for the rest. Cleared on logout. -const STORAGE_KEY = "bitvid:whDecryptedChunks:v1"; -const MAX_ENTRIES = 400; +import { createPersistedPlaintextCache } from "./persistedPlaintextCache.js"; -/** @type {Map | null} */ -let cache = null; -let dirty = false; +const cache = createPersistedPlaintextCache("bitvid:whDecryptedChunks:v1", 400); -function ensure() { - if (cache) { - return cache; - } - cache = new Map(); - try { - if (typeof localStorage !== "undefined") { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - cache = new Map(parsed); - } - } - } - } catch (_) { - cache = new Map(); - } - return cache; -} - -export function getCachedChunkPlaintext(eventId) { - if (!eventId) { - return null; - } - const c = ensure(); - return c.has(eventId) ? c.get(eventId) : null; -} - -export function setCachedChunkPlaintext(eventId, plaintext) { - if (!eventId || typeof plaintext !== "string" || !plaintext) { - return; - } - const c = ensure(); - if (c.get(eventId) === plaintext) { - return; - } - c.set(eventId, plaintext); - dirty = true; -} - -/** Persist pending writes once (call after a batch of decrypts). Bounds size. */ -export function flushDecryptedChunkCache() { - if (!dirty) { - return; - } - dirty = false; - try { - if (typeof localStorage === "undefined") { - return; - } - let entries = Array.from(cache.entries()); - if (entries.length > MAX_ENTRIES) { - entries = entries.slice(entries.length - MAX_ENTRIES); - cache = new Map(entries); - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); - } catch (_) { - // ignore persistence failures (quota/availability) - } -} - -export function clearWatchHistoryDecryptedChunkCache() { - cache = new Map(); - dirty = false; - try { - if (typeof localStorage !== "undefined") { - localStorage.removeItem(STORAGE_KEY); - } - } catch (_) { - // ignore - } -} +export const getCachedChunkPlaintext = cache.get; +export const setCachedChunkPlaintext = cache.set; +export const flushDecryptedChunkCache = cache.flush; +export const clearWatchHistoryDecryptedChunkCache = cache.clear; diff --git a/scripts/perf/dm-repro.mjs b/scripts/perf/dm-repro.mjs index f2380bb5..b845594e 100644 --- a/scripts/perf/dm-repro.mjs +++ b/scripts/perf/dm-repro.mjs @@ -23,6 +23,9 @@ const PEER_SK = generateSecretKey(); const PEER_PK = getPublicKey(PEER_SK); let extCalls = {}; +function resetExtCalls() { + extCalls = {}; +} let queue = Promise.resolve(); function viaExtension(method, fn) { extCalls[method] = (extCalls[method] || 0) + 1; @@ -83,47 +86,47 @@ async function main() { }; }, 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" }; + async function session(label) { + 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((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {}); 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); + 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(() => {}); + 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 }; + } catch (e) { + return { ok: false, reason: String(e).split("\n")[0] }; + } + }, USER_PK); + await page.waitForTimeout(1000); + await page.close(); + return result; + } + resetExtCalls(); + const cold = await session("cold"); + const coldCalls = { ...extCalls }; + resetExtCalls(); + const warm = await session("warm"); + const warmCalls = { ...extCalls }; + + const dCold = coldCalls["nip04.decrypt"] || 0; + const dWarm = warmCalls["nip04.decrypt"] || 0; 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(`COLD: ${JSON.stringify(cold)} | extension calls: ${JSON.stringify(coldCalls)}`); + console.log(`WARM: ${JSON.stringify(warm)} | extension calls: ${JSON.stringify(warmCalls)}`); + console.log(`nip04.decrypt: cold=${dCold} warm=${dWarm} -> persist cache ${dWarm < dCold ? "WORKS ✓" : "no effect"} (saved ${dCold - dWarm} extension calls)`); console.log("=========================================\n"); await browser.close();