[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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 14:42:56 -04:00
1 parent 5db18c9bd0
commit 11ff205fc0
2 files changed
+166 -2

No files matched your search

+57 -2
View File
@@ -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 = {}) {
+109
View File
@@ -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);