From 11ff205fc0a7e7ae8f2c557dd04fcfcf6382dbe1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:42:56 -0400 Subject: [PATCH] [watch-history] add newest-first, budget-capped chunk decryption (foundation) Foundation for bounded watch-history loading (slice B part 2). A deep history references many monthly chunks; under a NIP-46 remote signer each uncached chunk decrypt is a serialized published relay RPC, so decrypting them all up front blocks the first render and risks the relay rate limit. manager.fetch now: - sorts chunk events newest-first by created_at, so the decrypt budget is spent on the most recent history; - accepts a chunkDecryptLimit option that caps how many *uncached*, genuinely-encrypted chunks are decrypted per fetch. Cached/plaintext chunks are always decrypted (they cost no RPC) and never count against the limit. Default is Infinity -> no behavior change for existing callers; - returns deferredChunkCount so a caller can tell more history is available on demand (re-fetching with a larger limit reuses the chunk cache, so only the newly requested older chunks cost RPCs). Test: two uncached nip44 chunks (newest + older) with chunkDecryptLimit:1 asserts only the newest is decrypted (1 RPC), deferredChunkCount is 1, and only the newest chunk's items appear. Mutation-verified against both removing the cap and removing the newest-first sort. Co-Authored-By: Claude Opus 4.8 --- js/nostr/watchHistory.js | 59 ++++++++++++++++- tests/nostr/watchHistory.test.js | 109 +++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/js/nostr/watchHistory.js b/js/nostr/watchHistory.js index e9bf1468..66767194 100644 --- a/js/nostr/watchHistory.js +++ b/js/nostr/watchHistory.js @@ -1838,6 +1838,19 @@ class WatchHistoryManager { } async fetch(actorInput, options = {}) { + // Bound how many *uncached* watch-history chunks we decrypt up front. Each + // uncached chunk decrypt is a signer round-trip — and under a NIP-46 remote + // signer it is a published relay RPC routed through the serial 250ms queue, + // so a deep history (many monthly chunks) blocks the first render and risks + // the relay rate limit. Default is unlimited (no behavior change); callers + // opt into a finite limit and decrypt older chunks newest-first on demand. + // Already-cached and plaintext chunks are always decrypted (they cost no + // RPC) and never count against the limit. + const chunkDecryptLimitRaw = Number(options?.chunkDecryptLimit); + const chunkDecryptLimit = + Number.isFinite(chunkDecryptLimitRaw) && chunkDecryptLimitRaw >= 0 + ? Math.floor(chunkDecryptLimitRaw) + : Infinity; const actorCandidates = [actorInput]; if (typeof this.deps.getActivePubkey === "function") { actorCandidates.push(this.deps.getActivePubkey()); @@ -2069,6 +2082,7 @@ class WatchHistoryManager { const toolkitCacheRef = { current: null }; let decryptedItems = []; + let deferredChunkCount = 0; if (chunkIdentifiers.length && canAttemptDecrypt) { try { const results = await listEvents(readRelays, [ @@ -2078,9 +2092,40 @@ class WatchHistoryManager { "#d": chunkIdentifiers, }, ]); - const chunkEvents = Array.isArray(results) + const allChunkEvents = Array.isArray(results) ? results.flat().filter((event) => event && typeof event === "object") : []; + + // Decrypt newest chunks first so the most recent history is the part we + // spend the (bounded) decrypt budget on. A chunk is "free" when its + // content is already plaintext or its plaintext is cached — those never + // count against the limit. Only uncached, genuinely-encrypted chunks + // consume the budget; any beyond it are deferred (the caller can request + // older chunks later, which re-uses the chunk cache for the rest). + const sortedChunkEvents = [...allChunkEvents].sort( + (a, b) => (Number(b?.created_at) || 0) - (Number(a?.created_at) || 0), + ); + const chunkEvents = []; + let uncachedBudget = chunkDecryptLimit; + for (const event of sortedChunkEvents) { + const ciphertext = + typeof event.content === "string" ? event.content : ""; + const isFree = + !ciphertext || + looksLikeJsonStructure(ciphertext) || + Boolean(getCachedChunkPlaintext(event.id)); + if (isFree) { + chunkEvents.push(event); + continue; + } + if (uncachedBudget > 0) { + uncachedBudget -= 1; + chunkEvents.push(event); + } else { + deferredChunkCount += 1; + } + } + const chunkResults = await pMap( chunkEvents, async (event) => { @@ -2221,7 +2266,17 @@ class WatchHistoryManager { }; this.cache.set(actorKey, entry); this.persistEntry(actorKey, entry); - return { pointerEvent: latestEvent, items: flatItems, snapshotId: "" }; + return { + pointerEvent: latestEvent, + items: flatItems, + snapshotId: "", + // Number of older encrypted chunks left undecrypted because the caller + // capped the per-fetch decrypt budget. >0 means "more history available + // on demand"; the caller can re-fetch with a larger chunkDecryptLimit + // (already-decrypted chunks are served from cache, so only the newly + // requested older chunks cost RPCs). + deferredChunkCount, + }; } async resolve(actorInput, options = {}) { diff --git a/tests/nostr/watchHistory.test.js b/tests/nostr/watchHistory.test.js index 2d15affe..9ffc5ec3 100644 --- a/tests/nostr/watchHistory.test.js +++ b/tests/nostr/watchHistory.test.js @@ -450,6 +450,115 @@ test("fetchWatchHistory routes decryption by ciphertext format and never tries t } }); +test("fetchWatchHistory caps uncached chunk decryption to chunkDecryptLimit, newest chunk first", async () => { + // A deep watch history references many monthly chunks. Under a NIP-46 remote + // signer each uncached chunk decrypt is a serialized published RPC, so we cap + // how many we decrypt up front and spend that budget on the *newest* chunks. + // The older chunks are deferred (reported via deferredChunkCount) for an + // on-demand "load older" pass. + const actorHex = "7".repeat(64); + const newCipher = "bmV3LWNodW5rLWNpcGhlcnRleHQ"; // base64, no "?iv=" => nip44 + const oldCipher = "b2xkLWNodW5rLWNpcGhlcnRleHQ"; + + const pointerEvent = { + id: "pointer-cap", + pubkey: actorHex, + created_at: 1_700_500_000, + content: JSON.stringify({ + version: 2, + snapshot: "snap-cap", + items: [], + chunkIndex: 0, + totalChunks: 2, + }), + tags: [ + ["snapshot", "snap-cap"], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-new`], + ["a", `${WATCH_HISTORY_KIND}:${actorHex}:chunk-old`], + ], + }; + + const newChunk = { + id: "chunk-new-id", + pubkey: actorHex, + created_at: 1_700_500_200, // newer + content: newCipher, + tags: [["d", "chunk-new"], ["encrypted", "nip44"]], + }; + const oldChunk = { + id: "chunk-old-id", + pubkey: actorHex, + created_at: 1_700_400_000, // older + content: oldCipher, + tags: [["d", "chunk-old"], ["encrypted", "nip44"]], + }; + + const decryptedPayloads = []; + const signer = { + nip44Decrypt: async (pubkey, payload) => { + decryptedPayloads.push(payload); + if (payload === newCipher) { + return JSON.stringify({ + version: 2, + items: [{ type: "e", value: "from-new-chunk", watchedAt: 200 }], + }); + } + return JSON.stringify({ + version: 2, + items: [{ type: "e", value: "from-old-chunk", watchedAt: 100 }], + }); + }, + }; + + let listCall = 0; + const pool = { + async list() { + listCall += 1; + if (listCall === 1) { + return [pointerEvent]; + } + return [oldChunk, newChunk]; // relay returns out of recency order + }, + }; + + const manager = createWatchHistoryManager({ + getActivePubkey: () => actorHex, + resolveActiveSigner: () => signer, + shouldRequestExtensionPermissions: () => false, + getPool: () => pool, + getReadRelays: () => ["wss://relay.example"], + }); + + try { + const result = await fetchWatchHistory(manager, actorHex, { + forceRefresh: true, + chunkDecryptLimit: 1, + }); + assert.equal( + decryptedPayloads.length, + 1, + "only one chunk should be decrypted under chunkDecryptLimit: 1", + ); + assert.equal( + decryptedPayloads[0], + newCipher, + "the newest chunk (highest created_at) must be the one decrypted", + ); + assert.equal( + result.deferredChunkCount, + 1, + "the older chunk should be reported as deferred", + ); + assert.deepStrictEqual( + result.items.map((item) => item.value), + ["from-new-chunk"], + "only the newest chunk's items should be present under the cap", + ); + } finally { + manager.clear(); + } +}); + test("publishWatchHistorySnapshot uses injected nostr-tools helpers when signer cannot encrypt", async () => { const actorPubkey = "f".repeat(64); const sessionPrivateKey = "a".repeat(64);