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
This commit is contained in:
Michilis
2026-03-16 18:16:05 +00:00
parent a1509f21fc
commit a47868d17c
3 changed files with 24 additions and 14 deletions

View File

@@ -63,8 +63,8 @@ export const config = {
ipCooldownDays: envInt("IP_COOLDOWN_DAYS", 7), ipCooldownDays: envInt("IP_COOLDOWN_DAYS", 7),
maxClaimsPerIpPerPeriod: envInt("MAX_CLAIMS_PER_IP_PER_PERIOD", 1), maxClaimsPerIpPerPeriod: envInt("MAX_CLAIMS_PER_IP_PER_PERIOD", 1),
// Nostr (defaults include relays common for remote signers / NIP-05) // 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").split(",").map((s) => s.trim()), 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), relayTimeoutMs: envInt("RELAY_TIMEOUT_MS", 5000),
maxEventsFetch: envInt("MAX_EVENTS_FETCH", 500), maxEventsFetch: envInt("MAX_EVENTS_FETCH", 500),
metadataCacheHours: envInt("METADATA_CACHE_HOURS", 24), metadataCacheHours: envInt("METADATA_CACHE_HOURS", 24),

View File

@@ -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 minAgeSec = config.minAccountAgeDays * SECONDS_PER_DAY;
const cutoff = now - minAgeSec; const cutoff = now - minAgeSec;
console.log(`[eligibility] pubkey=${pubkey.slice(0, 8)}… firstSeen=${profile.nostrFirstSeenAt} cutoff=${cutoff} score=${profile.activityScore}`); 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) { if (profile.nostrFirstSeenAt === null || profile.nostrFirstSeenAt > cutoff) {
const ageDays = profile.nostrFirstSeenAt ? Math.floor((now - profile.nostrFirstSeenAt) / SECONDS_PER_DAY) : "null"; 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}`); console.log(`[eligibility] DENIED account_too_new: firstSeen age=${ageDays} days, required=${config.minAccountAgeDays}`);

View File

@@ -20,21 +20,23 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
} }
/** /**
* pool.get resolves as soon as ONE relay responds (or null if none do). * Query all relays for old events (created_at <= untilTimestamp). Uses querySync to wait
* Much faster than querySync which waits for EOSE from ALL relays. * for all relays and merge results, improving reliability vs pool.get (first relay only).
*/ */
async function probeOldEvent(pubkey: string, untilTimestamp: number): Promise<number | null> { async function probeOldEvent(pubkey: string, untilTimestamp: number): Promise<number | null> {
try { try {
const ev = await withTimeout( const evs = await withTimeout(
pool.get(config.nostrRelays, { pool.querySync(config.nostrRelays, {
kinds: [0, 1, 3], kinds: [0, 1, 3],
authors: [pubkey], authors: [pubkey],
until: untilTimestamp, until: untilTimestamp,
limit: 1, limit: 10,
}), }),
config.relayTimeoutMs 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 { } catch {
return null; return null;
} }
@@ -50,8 +52,14 @@ export async function fetchAndScorePubkey(pubkey: string, forceRefreshProfile =
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const cacheHours = config.metadataCacheHours; const cacheHours = config.metadataCacheHours;
const cacheValidUntil = (cached?.last_metadata_fetch_at ?? 0) + cacheHours * 3600; 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 { return {
nostrFirstSeenAt: cached.nostr_first_seen_at, nostrFirstSeenAt: cached.nostr_first_seen_at,
notesCount: cached.notes_count, 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. // 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. // The age probe uses querySync to wait for all relays and merge results for reliability.
const ageCutoff = nowSec - config.minAccountAgeDays * 86400;
const mainQueryPromise = withTimeout( const mainQueryPromise = withTimeout(
pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }), pool.querySync(config.nostrRelays, { kinds: [0, 1, 3], authors: [pubkey], limit: config.maxEventsFetch }),
config.relayTimeoutMs config.relayTimeoutMs
).then((r) => (Array.isArray(r) ? r : [])) ).then((r) => (Array.isArray(r) ? r : []))
.catch((): { kind: number; created_at: number; content?: string; tags: string[][] }[] => []); .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]); const [events, oldEventTimestamp] = await Promise.all([mainQueryPromise, ageProbePromise]);