From 2c62b959fde615dbbd14a3bf16ae1a424dd6acb2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:07:57 -0400 Subject: [PATCH] =?UTF-8?q?lists:=20cap=20login=20list=20subscriptions=20t?= =?UTF-8?q?o=20the=20=C2=A717=20read=20set=20(=E2=89=A48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cold-login REQ storm came from list subsystems subscribing/fetching across the user's full NIP-65 relay set (often ~20) instead of the documented §17 read cap. The fetchListIncrementally paths already route through capReadRelays, but the live subscriptions and direct pool.list calls bypassed it: - userBlocks.ensureBlockListSubscription: cap the live block-list subscription - subscriptions.ensureSubscriptionListSubscription: cap the live follows-list subscription - hashtagPreferencesService: cap the relay set used by pool.list + fetch capReadRelays keeps the user's own relays first and guarantees the reserved defaults, bounded to MAX_SUBSCRIBE_RELAYS (8). Trade-off accepted: a list event living only on a user's 9th+ relay may sync on a later pass rather than at login. Regression: tests/user-blocks-relay-cap.test.mjs. Co-Authored-By: Claude Opus 4.8 --- js/services/hashtagPreferencesService.js | 5 +- js/subscriptions.js | 11 ++-- js/userBlocks.js | 12 +++-- tests/user-blocks-relay-cap.test.mjs | 65 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 tests/user-blocks-relay-cap.test.mjs diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index ef85233f..68817795 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -22,7 +22,7 @@ import { import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; -import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "../nostr/toolkit.js"; import { relayManager } from "../relayManager.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, @@ -686,7 +686,8 @@ class HashtagPreferencesService { } const readRelays = relayManager.getReadRelayUrls(); - const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); + // capReadRelays bounds to the §17 read cap (≤8) so this doesn't fan out across the full NIP-65 set at login. + const relays = capReadRelays(readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS)); if (!relays.length) { if (wasLoadedForUser) { userLogger.warn( diff --git a/js/subscriptions.js b/js/subscriptions.js index 15081616..37653fa8 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -10,7 +10,7 @@ import { listVideoViewEvents, subscribeVideoViewEvents, } from "./nostrViewEventsFacade.js"; -import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; import { buildSubscriptionListEvent, @@ -730,6 +730,11 @@ class SubscriptionsManager { return null; } + // Bound the live follows-list subscription to the §17 read cap (≤8, user + // relays first + reserved defaults) so it doesn't fan out across the user's + // full NIP-65 set during the cold-login REQ storm. + const cappedRelays = capReadRelays(relays); + const key = `subscriptions:${normalized}`; this.subscriptionKey = key; @@ -751,7 +756,7 @@ class SubscriptionsManager { if (manager) { this.subscriptionHandle = manager.subscribe({ key, - relays, + relays: cappedRelays, filters, label: "subscription-list", onEvent: (event) => this.handleSubscriptionListEvent(event), @@ -762,7 +767,7 @@ class SubscriptionsManager { return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, - relays, + relays: cappedRelays, filters, label: "subscription-list", onEvent: (event) => this.handleSubscriptionListEvent(event), diff --git a/js/userBlocks.js b/js/userBlocks.js index 9d359813..6becbb2a 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -27,7 +27,7 @@ import { assertAnyRelayAccepted, } from "./nostrPublish.js"; import { profileCache } from "./state/profileCache.js"; -import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { DEFAULT_RELAY_URLS, capReadRelays } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; import { DEFAULT_NIP07_ENCRYPTION_METHODS, @@ -741,6 +741,12 @@ class UserBlockListManager { return null; } + // Bound the live block-list subscription to the §17 read cap (user relays + // first + reserved defaults, ≤8). Without this the subscription fanned out + // across the user's full NIP-65 set, a primary contributor to the cold-login + // REQ storm. + const cappedRelays = capReadRelays(relays); + const key = `block-list:${normalized}`; this.blockListSubscriptionKey = key; @@ -758,7 +764,7 @@ class UserBlockListManager { if (manager) { this.blockListSubscriptionHandle = manager.subscribe({ key, - relays, + relays: cappedRelays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), @@ -769,7 +775,7 @@ class UserBlockListManager { return relaySubscriptionService.ensureSubscription({ key, pool: nostrClient.pool, - relays, + relays: cappedRelays, filters, label: "block-list", onEvent: (event) => this.handleBlockListEvent(event), diff --git a/tests/user-blocks-relay-cap.test.mjs b/tests/user-blocks-relay-cap.test.mjs new file mode 100644 index 00000000..1bd001eb --- /dev/null +++ b/tests/user-blocks-relay-cap.test.mjs @@ -0,0 +1,65 @@ +// Regression: the live block-list subscription (ensureBlockListSubscription) used +// the user's full NIP-65 relay set, a primary contributor to the cold-login REQ +// storm. It must be bounded to the §17 read cap (capReadRelays, ≤8). +// +// Scenario (SCN-blocklist-subscription-relay-cap): +// Given a user with 20 read relays, +// When the live block-list subscription is (re)established, +// Then it subscribes over at most the §17-capped relay set, not all 20. + +import "./test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; + +const { userBlocks } = await import("../js/userBlocks.js"); +const { nostrClient } = await import("../js/nostrClientFacade.js"); +const { capReadRelays, MAX_SUBSCRIBE_RELAYS } = await import( + "../js/nostr/toolkit.js" +); + +if (typeof globalThis.window === "undefined") { + globalThis.window = {}; +} + +test("ensureBlockListSubscription caps the live subscription relay set", () => { + const actor = "f".repeat(64); + const manyRelays = Array.from( + { length: 20 }, + (_, i) => `wss://relay-${i}.example`, + ); + + const originalPool = nostrClient.pool; + const originalGetManager = nostrClient.getSubscriptionManager; + + let captured = null; + try { + nostrClient.pool = { close() {} }; + nostrClient.getSubscriptionManager = () => ({ + subscribe: (args) => { + captured = args; + return { close() {} }; + }, + }); + + userBlocks.ensureBlockListSubscription(actor, manyRelays); + + assert.ok(captured, "subscription manager should have been invoked"); + const expected = capReadRelays(manyRelays); + assert.deepEqual( + captured.relays, + expected, + "live subscription must use the §17-capped relay set", + ); + assert.ok( + captured.relays.length <= MAX_SUBSCRIBE_RELAYS, + `relay set must be capped to ${MAX_SUBSCRIBE_RELAYS}, got ${captured.relays.length}`, + ); + assert.ok( + captured.relays.length < manyRelays.length, + "must not fan out across the full 20-relay NIP-65 set", + ); + } finally { + nostrClient.pool = originalPool; + nostrClient.getSubscriptionManager = originalGetManager; + } +});