perf(dm): persist decrypted DM results so reloads skip the extension

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-12 19:55:07 -04:00
1 parent bbf8d10473
commit 8171fcb347
5 files changed
+184 -129

No files matched your search

+6
View File
@@ -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);
};
+30
View File
@@ -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);
};
+97
View File
@@ -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<string, string> | 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 };
}
+10 -91
View File
@@ -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<string, string> | 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;
+41 -38
View File
@@ -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();