From 3f19294a8e4c1650f933ccaededacd5085fe7ed1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:43:35 -0400 Subject: [PATCH] fix(admin-lists): force full fetch so persisted lastSeen can't hide current list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchLatestListEvent fetched admin lists via fetchListIncrementally without an explicit `since`, so it relied on the persisted per-relay lastSeen. Admin lists are replaceable events with a single current version whose created_at does not advance, so once lastSeen == that created_at, every later load queries since=lastSeen+1 and the relay returns nothing — perpetually hiding the current list and dropping admin lists whenever the local cache is absent. Pass since: 0 to force a full fetch of the (tiny) current event each time. Other list callers anchor an explicit since to their own cached state; admin lists have no comparable per-list timestamp, so always re-fetching current is both correct and cheap. Adds tests/nostr/relayBatchFetcherReplaceable.test.mjs documenting the replaceable-event since-gate at the fetcher boundary: relying on persisted lastSeen hides the current event; since: 0 returns it. Co-Authored-By: Claude Opus 4.8 --- js/adminListStore.js | 5 + .../relayBatchFetcherReplaceable.test.mjs | 94 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/nostr/relayBatchFetcherReplaceable.test.mjs diff --git a/js/adminListStore.js b/js/adminListStore.js index 8c285b26..83eddb5e 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -576,6 +576,11 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") { pubkey, dTag, relayUrls: relays, + // Force a full fetch: admin lists are replaceable events whose + // created_at doesn't advance, so a persisted lastSeen would gate + // since=lastSeen+1 and perpetually hide the current event across + // reloads. See tests/nostr/relayBatchFetcherReplaceable.test.mjs. + since: 0, }), timeoutPromise, ]); diff --git a/tests/nostr/relayBatchFetcherReplaceable.test.mjs b/tests/nostr/relayBatchFetcherReplaceable.test.mjs new file mode 100644 index 00000000..719d0fd9 --- /dev/null +++ b/tests/nostr/relayBatchFetcherReplaceable.test.mjs @@ -0,0 +1,94 @@ +// Regression test documenting why replaceable-list fetches must not be gated by +// a persisted per-relay lastSeen, and why admin lists pass `since: 0`. +// +// Replaceable events (NIP-51/NIP-33 lists: mute 10000, lists 30000/30002, +// hashtag prefs 30015, admin lists) have exactly ONE current version. The +// incremental optimization stores lastSeen = current created_at, then queries +// `since = lastSeen + 1` on the next load. For a replaceable event whose +// created_at never advances, that filter perpetually excludes the current +// event, so the fetch returns nothing even though the list exists. Callers that +// lack a durable content cache then see an empty list. +// +// Scenario (SCN-replaceable-since-gate): +// Given a relay holding one replaceable list event at created_at = T, and a +// persisted lastSeen = T, +// When the list is fetched relying on the persisted lastSeen, +// Then the current event is hidden (returns empty) — the latent bug. +// When the list is fetched with an explicit since: 0 (full fetch), +// Then the current event is returned — the fix used by admin lists. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { RelayBatchFetcher } from "../../js/nostr/relayBatchFetcher.js"; + +const PUBKEY = "a".repeat(64); +const RELAY = "wss://r1.example.com"; +const T = 1000; + +// One current replaceable list event living on the relay at created_at = T. +const currentEvent = { + id: "current-list-event", + kind: 30000, + pubkey: PUBKEY, + created_at: T, + tags: [["d", "admin:editors"]], + content: "", +}; + +function makeFetcher() { + const client = { + relays: [RELAY], + readRelays: [], + getHealthyRelays: (list) => list, + ensurePool: async () => ({ list: async () => [] }), + // Persisted lastSeen says we already saw the current version at time T. + getSyncLastSeen: () => T, + updateSyncLastSeen: () => {}, + markRelayUnreachable: () => {}, + }; + + // Behavioral relay clone: returns the current replaceable event only when the + // query is not gated past T (i.e. since is absent or <= T). + const fetchFn = async (_relayUrl, filter) => { + const since = filter?.since; + if (since === undefined || since <= T) { + return [currentEvent]; + } + return []; + }; + + return { fetcher: new RelayBatchFetcher(client), fetchFn }; +} + +describe("fetchListIncrementally + replaceable events", () => { + it("relying on persisted lastSeen hides the current replaceable event (documents the bug)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + // no `since` => uses persisted lastSeen = T => filter.since = T + 1 + }); + assert.equal( + events.length, + 0, + "persisted lastSeen gates since=T+1, hiding the current event", + ); + }); + + it("since: 0 forces a full fetch and returns the current event (the admin-list fix)", async () => { + const { fetcher, fetchFn } = makeFetcher(); + const events = await fetcher.fetchListIncrementally({ + kind: 30000, + pubkey: PUBKEY, + dTag: "admin:editors", + relayUrls: [RELAY], + fetchFn, + since: 0, + }); + assert.equal(events.length, 1, "full fetch must return the current event"); + assert.equal(events[0].id, currentEvent.id); + }); +});