From cf6db0f0a2afc8bc4b3fbb2680ca2ce4973e6f8a Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 13:25:35 +0000 Subject: [PATCH 1/2] [#250] Fix Farcaster identity: Steemhunt primary, Neynar fallback Rewrite lib/farcaster.ts to use Steemhunt's free Farcaster Indexer API as the primary lookup, falling back to Neynar only when NEYNAR_API_KEY is configured. Simple in-memory cache (1h TTL) with in-flight dedup. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/farcaster.ts | 111 +++++++++++++++++++++++++++++++---------------- 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/lib/farcaster.ts b/lib/farcaster.ts index 14c89857..5a178521 100644 --- a/lib/farcaster.ts +++ b/lib/farcaster.ts @@ -1,8 +1,10 @@ /** - * Farcaster identity lookup via Neynar API. + * Farcaster identity lookup — Steemhunt primary, Neynar fallback. * - * Resolves an Ethereum address to a Farcaster username + avatar. - * Results are cached in memory to avoid redundant API calls. + * Steemhunt's Farcaster Indexer (https://fc.hunt.town) is free and requires + * no API key. Neynar is used as a fallback only when NEYNAR_API_KEY is set. + * + * Simple in-memory cache with 1h TTL, 3s request timeout. */ export interface FarcasterProfile { @@ -12,59 +14,92 @@ export interface FarcasterProfile { pfpUrl: string | null; } +const STEEMHUNT_BASE = "https://fc.hunt.town"; const NEYNAR_BASE = "https://api.neynar.com/v2/farcaster"; +const REQUEST_TIMEOUT_MS = 3000; +const CACHE_TTL_MS = 3600_000; // 1 hour + +const cache = new Map(); +const inFlight = new Map>(); -const CACHE_TTL_MS = 3600_000; // 1 hour for successful lookups -const cache = new Map(); +/** + * Try Steemhunt first, then Neynar if configured. Returns null if both fail. + */ +async function steemhuntLookup(address: string): Promise { + const res = await fetch(`${STEEMHUNT_BASE}/users/byWallet/${address}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!res.ok) return null; + const data = await res.json(); + if (!data || !data.fid) return null; + return { + fid: data.fid, + username: data.username, + displayName: data.displayName ?? data.username, + pfpUrl: data.pfpUrl ?? null, + }; +} -function getApiKey(): string | undefined { - return process.env.NEYNAR_API_KEY; +async function neynarLookup(address: string): Promise { + const apiKey = process.env.NEYNAR_API_KEY; + if (!apiKey) return null; + const res = await fetch( + `${NEYNAR_BASE}/user/bulk-by-address?addresses=${address}`, + { + headers: { accept: "application/json", "x-api-key": apiKey }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + if (!res.ok) return null; + const json = await res.json(); + const users = json[address]; + if (!Array.isArray(users) || users.length === 0) return null; + const user = users[0]; + return { + fid: user.fid, + username: user.username, + displayName: user.display_name ?? user.username, + pfpUrl: user.pfp_url ?? null, + }; } /** * Look up a Farcaster profile by Ethereum address. - * Returns `null` when no Farcaster account is linked or the API is unavailable. - * Only caches successful lookups with TTL; transient errors are never cached. + * Returns `null` when no Farcaster account is linked or both APIs are unavailable. */ export async function lookupByAddress( address: string, ): Promise { const key = address.toLowerCase(); + // Check cache const cached = cache.get(key); if (cached && cached.expiresAt > Date.now()) return cached.profile; if (cached) cache.delete(key); - const apiKey = getApiKey(); - if (!apiKey) return null; - - try { - const res = await fetch( - `${NEYNAR_BASE}/user/bulk-by-address?addresses=${key}`, - { - headers: { accept: "application/json", "x-api-key": apiKey }, - next: { revalidate: 3600 }, - }, - ); - - if (!res.ok) return null; - - const json = await res.json(); - const users = json[key]; + // Deduplicate in-flight requests + const existing = inFlight.get(key); + if (existing) return existing; - if (!Array.isArray(users) || users.length === 0) return null; + const promise = (async () => { + try { + // Steemhunt first (free, no key needed) + const profile = await steemhuntLookup(key).catch(() => null); + if (profile) { + cache.set(key, { profile, expiresAt: Date.now() + CACHE_TTL_MS }); + return profile; + } - const user = users[0]; - const profile: FarcasterProfile = { - fid: user.fid, - username: user.username, - displayName: user.display_name ?? user.username, - pfpUrl: user.pfp_url ?? null, - }; + // Neynar fallback + const fallback = await neynarLookup(key).catch(() => null); + cache.set(key, { profile: fallback, expiresAt: Date.now() + CACHE_TTL_MS }); + return fallback; + } finally { + inFlight.delete(key); + } + })(); - cache.set(key, { profile, expiresAt: Date.now() + CACHE_TTL_MS }); - return profile; - } catch { - return null; - } + inFlight.set(key, promise); + return promise; } From 15ddc5ac3fdc1946d2efc76bf17c3f0573e94b00 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 17 Mar 2026 13:27:58 +0000 Subject: [PATCH 2/2] [#250] Fix: only cache null on confirmed not-found, not transient errors Distinguish between "API confirmed wallet not linked" (cache null for 1h) and "transient error" (don't cache, retry next request). Addresses T2a review feedback on PR #253. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/farcaster.ts | 49 +++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/farcaster.ts b/lib/farcaster.ts index 5a178521..d0aaa091 100644 --- a/lib/farcaster.ts +++ b/lib/farcaster.ts @@ -22,17 +22,19 @@ const CACHE_TTL_MS = 3600_000; // 1 hour const cache = new Map(); const inFlight = new Map>(); -/** - * Try Steemhunt first, then Neynar if configured. Returns null if both fail. - */ -async function steemhuntLookup(address: string): Promise { +// Sentinel: API responded successfully but wallet is not linked to Farcaster +const NOT_FOUND = Symbol("NOT_FOUND"); +type LookupResult = FarcasterProfile | typeof NOT_FOUND | null; // null = transient error + +async function steemhuntLookup(address: string): Promise { const res = await fetch(`${STEEMHUNT_BASE}/users/byWallet/${address}`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); - if (!res.ok) return null; + if (res.status === 404) return NOT_FOUND; // confirmed not linked + if (!res.ok) return null; // transient error const data = await res.json(); - if (!data || !data.fid) return null; + if (!data || !data.fid) return NOT_FOUND; return { fid: data.fid, username: data.username, @@ -41,7 +43,7 @@ async function steemhuntLookup(address: string): Promise { +async function neynarLookup(address: string): Promise { const apiKey = process.env.NEYNAR_API_KEY; if (!apiKey) return null; const res = await fetch( @@ -51,10 +53,10 @@ async function neynarLookup(address: string): Promise { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }, ); - if (!res.ok) return null; + if (!res.ok) return null; // transient error const json = await res.json(); const users = json[address]; - if (!Array.isArray(users) || users.length === 0) return null; + if (!Array.isArray(users) || users.length === 0) return NOT_FOUND; const user = users[0]; return { fid: user.fid, @@ -85,16 +87,29 @@ export async function lookupByAddress( const promise = (async () => { try { // Steemhunt first (free, no key needed) - const profile = await steemhuntLookup(key).catch(() => null); - if (profile) { - cache.set(key, { profile, expiresAt: Date.now() + CACHE_TTL_MS }); - return profile; + const steemhunt = await steemhuntLookup(key).catch(() => null); + if (steemhunt && steemhunt !== NOT_FOUND) { + cache.set(key, { profile: steemhunt, expiresAt: Date.now() + CACHE_TTL_MS }); + return steemhunt; } - // Neynar fallback - const fallback = await neynarLookup(key).catch(() => null); - cache.set(key, { profile: fallback, expiresAt: Date.now() + CACHE_TTL_MS }); - return fallback; + // If Steemhunt confirmed "not linked", skip Neynar and cache null + if (steemhunt === NOT_FOUND) { + cache.set(key, { profile: null, expiresAt: Date.now() + CACHE_TTL_MS }); + return null; + } + + // Steemhunt had a transient error — try Neynar fallback + const neynar = await neynarLookup(key).catch(() => null); + if (neynar && neynar !== NOT_FOUND) { + cache.set(key, { profile: neynar, expiresAt: Date.now() + CACHE_TTL_MS }); + return neynar; + } + // Only cache null if Neynar confirmed not found; skip cache on transient errors + if (neynar === NOT_FOUND) { + cache.set(key, { profile: null, expiresAt: Date.now() + CACHE_TTL_MS }); + } + return null; } finally { inFlight.delete(key); }