perf(dm): persist the decrypt cache so each message decrypts once, ever

The last Amber pain: a fresh launch re-decrypted the whole inbox (2 signer ops
per wrap). Decrypted messages are now persisted to IndexedDB
(src/lib/dmDecryptCache.ts, keyed viewerPk:wrapId, bounded 4000/account, pruned
oldest-first) and hydrated once per session before the first lookup, so a wrap is
decrypted at most once ever. Cleared per-account on logout (plaintext at rest
otherwise readable by another account on the device).
This commit is contained in:
SkateSpots 2026-10-13 18:58:35 +07:00
parent 707dcaf644
commit 10299d07f2
4 changed files with 148 additions and 15 deletions

View file

@ -543,8 +543,14 @@ session with **no other memory of this project**. So:
`lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their `lib/cordn/{client,crypto}.ts` are **kept but unwired** (marked in their
headers) — re-wiring is a deliberate decision, not a one-liner, because it headers) — re-wiring is a deliberate decision, not a one-liner, because it
split chat history. Group reads widened to **500** wraps (1:1 stays 200). split chat history. Group reads widened to **500** wraps (1:1 stays 200).
(Still not done: persisting the decrypt cache across app restarts, so the first **Persisted DM decrypt cache (2026-10-13):** `src/lib/dmDecryptCache.ts` (Dexie
load per launch still decrypts everything.) `SkateSpotsDmCacheDB`, keyed `viewerPk:wrapId`, bounded 4000/account, pruned
oldest-first on hydrate) is used by `decryptGiftWrap` — so a message is
decrypted **once, ever**, killing the first-load-per-launch Amber storm.
⚠️ It stores **plaintext DMs at rest** in IndexedDB (device-local, never
published) — the trade-off for not re-prompting; it is cleared on **logout**
(`AccountSwitcher`) so another account on the device can't read it.
`clearDmDecryptCache()` is exported for a future "clear cache" action.
- **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat - **Sasquatch AI guide** (`src/components/SasquatchGuide.tsx`): the chat
queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and queries public relays **directly** (`useCVMClient.ts`, 3 fixed relays) and
does **not** use the ContextVM server. AI goes through our own **server-side does **not** use the ContextVM server. AI goes through our own **server-side

View file

@ -11,6 +11,7 @@ import {
} from '@/components/ui/dropdown-menu.tsx'; } from '@/components/ui/dropdown-menu.tsx';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx';
import { WalletModal } from '@/components/WalletModal'; import { WalletModal } from '@/components/WalletModal';
import { clearDmDecryptCache } from '@/lib/dmDecryptCache';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts'; import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -97,7 +98,12 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
<span>{t('settingsUi.accountSwitcher.addAccount')}</span> <span>{t('settingsUi.accountSwitcher.addAccount')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => removeLogin(currentUser.id)} onClick={() => {
// Drop this account's plaintext DM cache (device-local) on logout so
// another account on the same device can't read it from IndexedDB.
void clearDmDecryptCache(currentUser.id).catch(() => {});
removeLogin(currentUser.id);
}}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500' className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
> >
<LogOut className='w-4 h-4' /> <LogOut className='w-4 h-4' />

View file

@ -11,6 +11,12 @@ import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabil
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { SKATE_RELAY } from '@/lib/types'; import { SKATE_RELAY } from '@/lib/types';
import type { RelayMetadata } from '@/contexts/AppContext'; import type { RelayMetadata } from '@/contexts/AppContext';
import {
loadDmDecryptCache,
saveDmDecryptKey,
pruneDmDecryptCache,
type CachedDm,
} from '@/lib/dmDecryptCache';
/** /**
* Read relays for DMs: the user's NIP-65 read relays plus their own NIP-17 * Read relays for DMs: the user's NIP-65 read relays plus their own NIP-17
@ -220,18 +226,7 @@ function createRumor(
} }
/** A decrypted NIP-17 message (the fields every reader needs). */ /** A decrypted NIP-17 message (the fields every reader needs). */
interface DecryptedDm { type DecryptedDm = CachedDm;
id: string;
pubkey: string;
content: string;
createdAt: number;
/** The rumor's `p` tags (raw recipients, sender NOT necessarily included). */
recipients: string[];
/** Sender + recipients, deduped (the group identity). */
participants: string[];
subject?: string;
replyTo?: string;
}
/** /**
* Session cache of decrypted gift wraps, keyed by `viewerPk:wrapId`. * Session cache of decrypted gift wraps, keyed by `viewerPk:wrapId`.
@ -250,6 +245,35 @@ const DM_DECRYPT_CACHE_MAX = 4000;
const dmDecryptCache = new Map<string, DecryptedDm>(); const dmDecryptCache = new Map<string, DecryptedDm>();
const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>(); const dmDecryptInFlight = new Map<string, Promise<DecryptedDm | null>>();
// Which accounts' persisted caches have been loaded into memory this session,
// and any hydration in flight (so concurrent readers await the same load).
const dmCacheHydrated = new Set<string>();
const dmCacheHydrating = new Map<string, Promise<void>>();
/** Load an account's persisted decrypt cache into memory (once per session). */
async function ensureDmCacheHydrated(viewerPk: string): Promise<void> {
if (dmCacheHydrated.has(viewerPk)) return;
const flying = dmCacheHydrating.get(viewerPk);
if (flying) return flying;
const task = (async () => {
try {
const rows = await loadDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX);
for (const [k, dm] of rows) {
if (!dmDecryptCache.has(k)) dmDecryptCache.set(k, dm);
}
dmCacheHydrated.add(viewerPk);
// Keep the on-disk set bounded (oldest dropped first).
void pruneDmDecryptCache(viewerPk, DM_DECRYPT_CACHE_MAX).catch(() => {});
} catch {
// A cache read failure just means we decrypt again — never fatal.
} finally {
dmCacheHydrating.delete(viewerPk);
}
})();
dmCacheHydrating.set(viewerPk, task);
return task;
}
/** The signer surface DM reading needs (NIP-44 decrypt). */ /** The signer surface DM reading needs (NIP-44 decrypt). */
interface DmSigner { interface DmSigner {
nip44?: { nip44?: {
@ -263,6 +287,9 @@ async function decryptGiftWrap(
wrap: NostrEvent, wrap: NostrEvent,
): Promise<DecryptedDm | null> { ): Promise<DecryptedDm | null> {
const key = `${viewerPk}:${wrap.id}`; const key = `${viewerPk}:${wrap.id}`;
// Hydrate the persisted cache once per account before the first lookup, so a
// relaunch reuses decryptions that already happened on this device.
await ensureDmCacheHydrated(viewerPk);
const cached = dmDecryptCache.get(key); const cached = dmDecryptCache.get(key);
if (cached) return cached; if (cached) return cached;
const flying = dmDecryptInFlight.get(key); const flying = dmDecryptInFlight.get(key);
@ -298,6 +325,8 @@ async function decryptGiftWrap(
if (oldest !== undefined) dmDecryptCache.delete(oldest); if (oldest !== undefined) dmDecryptCache.delete(oldest);
} }
dmDecryptCache.set(key, dm); dmDecryptCache.set(key, dm);
// Persist so this wrap is never decrypted again (fire-and-forget).
void saveDmDecryptKey(key, viewerPk, dm).catch(() => {});
return dm; return dm;
} catch { } catch {
// Not cached — a transient failure may succeed on the next attempt. // Not cached — a transient failure may succeed on the next attempt.

92
src/lib/dmDecryptCache.ts Normal file
View file

@ -0,0 +1,92 @@
/**
* Persisted DM decrypt cache.
* ─────────────────────────────────────────────────────────────────────────────
* NIP-17 reading is expensive with an external signer: every gift wrap costs two
* signer operations (decrypt the seal, decrypt the rumor). The in-memory cache in
* `useDirectMessages` coalesces this within a session, but a fresh launch had to
* decrypt the whole inbox again — the last remaining Amber "pop-up storm".
*
* This module persists decrypted messages to IndexedDB so a message is decrypted
* **once, ever**. Keyed `viewerPk:wrapId`; bounded per account; pruned oldest-first.
*
* ⚠️ Privacy: this stores the **plaintext** of your DMs (sender, body, recipients)
* at rest in this origin's IndexedDB — the same content you already see on screen.
* It is device-local, never published, and scoped to the logged-in pubkey. Use
* {@link clearDmDecryptCache} to wipe it (e.g. from a future "clear cache" action).
*/
import Dexie, { type Table } from 'dexie';
export interface CachedDm {
id: string;
pubkey: string;
content: string;
createdAt: number;
/** The rumor's `p` tags (raw recipients). */
recipients: string[];
/** Sender + recipients, deduped (the group identity). */
participants: string[];
subject?: string;
replyTo?: string;
}
interface DmRow {
/** `${viewerPk}:${wrapId}` — unique per recipient, since decryption is per-key. */
key: string;
viewerPk: string;
cachedAt: number;
dm: CachedDm;
}
class SkateSpotsDmCacheDB extends Dexie {
dm!: Table<DmRow, string>;
constructor() {
super('SkateSpotsDmCacheDB');
this.version(1).stores({
dm: 'key, viewerPk, cachedAt',
});
}
}
let db: SkateSpotsDmCacheDB | null = null;
function getDB(): SkateSpotsDmCacheDB {
db ??= new SkateSpotsDmCacheDB();
return db;
}
/** Load this account's cached messages, newest first, capped at `limit`. */
export async function loadDmDecryptCache(
viewerPk: string,
limit: number,
): Promise<Array<[string, CachedDm]>> {
const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray();
rows.sort((a, b) => b.cachedAt - a.cachedAt);
return rows.slice(0, limit).map((r) => [r.key, r.dm]);
}
/** Persist one decrypted message (fire-and-forget friendly). */
export async function saveDmDecryptKey(
key: string,
viewerPk: string,
dm: CachedDm,
): Promise<void> {
await getDB().dm.put({ key, viewerPk, cachedAt: Date.now(), dm });
}
/** Keep at most `max` rows for this account (oldest dropped first). */
export async function pruneDmDecryptCache(viewerPk: string, max: number): Promise<void> {
const rows = await getDB().dm.where('viewerPk').equals(viewerPk).toArray();
if (rows.length <= max) return;
rows.sort((a, b) => a.cachedAt - b.cachedAt);
await getDB().dm.bulkDelete(rows.slice(0, rows.length - max).map((r) => r.key));
}
/** Wipe the cache for one account, or all accounts when omitted. */
export async function clearDmDecryptCache(viewerPk?: string): Promise<void> {
if (!viewerPk) {
await getDB().dm.clear();
return;
}
await getDB().dm.where('viewerPk').equals(viewerPk).delete();
}