From a47868d17c244b1e50206514fc0dff40df518c89 Mon Sep 17 00:00:00 2001 From: Michilis Date: Mon, 16 Mar 2026 18:16:05 +0000 Subject: [PATCH] Fix account_too_new false positive for old Nostr accounts - Force refresh profile when denying account_too_new, then re-check - Use querySync for age probe instead of pool.get (all relays, more reliable) - Add default relays: nostr.info, snort.social, mostr.pub - Skip cache when cached first-seen is borderline (10-14 days) Made-with: Cursor --- backend/src/config.ts | 4 ++-- backend/src/services/eligibility.ts | 6 +++++- backend/src/services/nostr.ts | 28 +++++++++++++++++----------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/src/config.ts b/backend/src/config.ts index 953fc87..f7c1fa5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -63,8 +63,8 @@ export const config = { ipCooldownDays: envInt("IP_COOLDOWN_DAYS", 7), maxClaimsPerIpPerPeriod: envInt("MAX_CLAIMS_PER_IP_PER_PERIOD", 1), - // Nostr (defaults include relays common for remote signers / NIP-05) - nostrRelays: (process.env.NOSTR_RELAYS ?? "wss://relay.damus.io,wss://relay.nostr.band,wss://relay.getalby.com,wss://nos.lol").split(",").map((s) => s.trim()), + // Nostr (defaults include relays common for remote signers, snort, iris, and NIP-05) + nostrRelays: (process.env.NOSTR_RELAYS ?? "wss://relay.damus.io,wss://relay.nostr.band,wss://relay.getalby.com,wss://nos.lol,wss://relay.nostr.info,wss://relay.snort.social,wss://relay.mostr.pub").split(",").map((s) => s.trim()), relayTimeoutMs: envInt("RELAY_TIMEOUT_MS", 5000), maxEventsFetch: envInt("MAX_EVENTS_FETCH", 500), metadataCacheHours: envInt("METADATA_CACHE_HOURS", 24), diff --git a/backend/src/services/eligibility.ts b/backend/src/services/eligibility.ts index ee6cd24..02e991b 100644 --- a/backend/src/services/eligibility.ts +++ b/backend/src/services/eligibility.ts @@ -93,10 +93,14 @@ export async function checkEligibility(pubkey: string, ipHash: string): Promise< }; } - const profile = await fetchAndScorePubkey(pubkey); + let profile = await fetchAndScorePubkey(pubkey); const minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY; const cutoff = now - minAgeSec; console.log(`[eligibility] pubkey=${pubkey.slice(0, 8)}… firstSeen=${profile.nostrFirstSeenAt} cutoff=${cutoff} score=${profile.activityScore}`); + if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) { + profile = await fetchAndScorePubkey(pubkey, true); + console.log(`[eligibility] force-refresh after account_too_new: firstSeen=${profile.nostrFirstSeenAt}`); + } if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) { const ageDays = profile.nostrFirstSeenAt ? Math.floor((now - profile.nostrFirstSeenAt) / SECONDS_PER_DAY) : "null"; console.log(`[eligibility] DENIED account_too_new: firstSeen age=${ageDays} days, required=${config.minAccountAgeDays}`); diff --git a/backend/src/services/nostr.ts b/backend/src/services/nostr.ts index 6e6bd5e..7744a90 100644 --- a/backend/src/services/nostr.ts +++ b/backend/src/services/nostr.ts @@ -20,21 +20,23 @@ function withTimeout(promise: Promise, ms: number): Promise { } /** - * pool.get resolves as soon as ONE relay responds (or null if none do). - * Much faster than querySync which waits for EOSE from ALL relays. + * Query all relays for old events (created_at <= untilTimestamp). Uses querySync to wait + * for all relays and merge results, improving reliability vs pool.get (first relay only). */ async function probeOldEvent(pubkey: string, untilTimestamp: number): Promise { try { - const ev = await withTimeout( - pool.get(config.nostrRelays, { + const evs = await withTimeout( + pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], until: untilTimestamp, - limit: 1, + limit: 10, }), config.relayTimeoutMs ); - return ev ? ev.created_at : null; + const arr = Array.isArray(evs) ? evs : []; + if (arr.length === 0) return null; + return Math.min(...arr.map((e) => e.created_at)); } catch { return null; } @@ -50,8 +52,14 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = const nowSec = Math.floor(Date.now() / 1000); const cacheHours = config.metadataCacheHours; const cacheValidUntil = (cached?.last_metadata_fetch_at ?? 0) + cacheHours * 3600; + const ageCutoffSec = nowSec - config.minAccountAgeDays * 86400; + const borderlineWindowSec = 4 * 86400; + const isBorderline = + cached?.nostr_first_seen_at != null && + cached.nostr_first_seen_at >= ageCutoffSec && + cached.nostr_first_seen_at <= ageCutoffSec + borderlineWindowSec; - if (!forceRefreshProfile && cached && cacheValidUntil > nowSec) { + if (!forceRefreshProfile && cached && cacheValidUntil > nowSec && !isBorderline) { return { nostrFirstSeenAt: cached.nostr_first_seen_at, notesCount: cached.notes_count, @@ -62,16 +70,14 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile = } // Run the main activity query and the account-age probe in parallel. - // The age probe uses pool.get (resolves on first relay hit) so it's fast. - const ageCutoff = nowSec - config.minAccountAgeDays * 86400; - + // The age probe uses querySync to wait for all relays and merge results for reliability. const mainQueryPromise = withTimeout( pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }), config.relayTimeoutMs ).then((r) => (Array.isArray(r) ? r : [])) .catch((): { kind: number; created_at: number; content?: string; tags: string[][] }[] => []); - const ageProbePromise = probeOldEvent(pubkey, ageCutoff); + const ageProbePromise = probeOldEvent(pubkey, ageCutoffSec); const [events, oldEventTimestamp] = await Promise.all([mainQueryPromise, ageProbePromise]);