fix(admin-lists): force full fetch so persisted lastSeen can't hide current list

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-12 09:44:36 -04:00
1 parent 818fe16254
commit 3f19294a8e
2 files changed
+99

No files matched your search

+5
View File
@@ -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,
]);
@@ -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);
});
});