diff --git a/lib/farcaster.ts b/lib/farcaster.ts index 14c89857..d0aaa091 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,107 @@ 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(); +// 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.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 NOT_FOUND; + 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; // transient error + const json = await res.json(); + const users = json[address]; + if (!Array.isArray(users) || users.length === 0) return NOT_FOUND; + 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; + // Deduplicate in-flight requests + const existing = inFlight.get(key); + if (existing) return existing; - const json = await res.json(); - const users = json[key]; + const promise = (async () => { + try { + // Steemhunt first (free, no key needed) + 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; + } - if (!Array.isArray(users) || users.length === 0) return null; + // 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; + } - const user = users[0]; - const profile: FarcasterProfile = { - fid: user.fid, - username: user.username, - displayName: user.display_name ?? user.username, - pfpUrl: user.pfp_url ?? 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); + } + })(); - cache.set(key, { profile, expiresAt: Date.now() + CACHE_TTL_MS }); - return profile; - } catch { - return null; - } + inFlight.set(key, promise); + return promise; }