/** * Zerion DeFi positions — fetches protocol positions (Aave, Uniswap, etc.) * with 5-minute in-memory cache. Requires ZERION_API_KEY env var. */ export interface DefiPosition { protocol: string; type: string; // "deposit", "loan", "staked", "locked", "reward" chain: string; // chain name chainId: string; tokens: Array<{ symbol: string; amount: number; valueUSD: number }>; totalValueUSD: number; } interface CacheEntry { positions: DefiPosition[]; ts: number; } const TTL = 5 * 60 * 1000; const cache = new Map(); const inFlight = new Map>(); // Zerion chain ID → our chain ID mapping const ZERION_CHAIN_MAP: Record = { ethereum: "1", optimism: "10", "gnosis-chain": "100", "xdai": "100", polygon: "137", base: "8453", arbitrum: "42161", "binance-smart-chain": "56", avalanche: "43114", celo: "42220", "zksync-era": "324", }; function getApiKey(): string | null { return process.env.ZERION_API_KEY || null; } /** * Fetch DeFi protocol positions for an address via Zerion API. * Returns empty array if ZERION_API_KEY is not set. */ export async function getDefiPositions(address: string): Promise { const apiKey = getApiKey(); if (!apiKey) return []; const lower = address.toLowerCase(); // Check cache const cached = cache.get(lower); if (cached && Date.now() - cached.ts < TTL) return cached.positions; // Deduplicate concurrent requests const pending = inFlight.get(lower); if (pending) return pending; const promise = (async (): Promise => { try { const auth = btoa(`${apiKey}:`); const url = `https://api.zerion.io/v1/wallets/${lower}/positions/?filter[positions]=only_complex¤cy=usd&filter[trash]=only_non_trash`; const res = await fetch(url, { headers: { accept: "application/json", authorization: `Basic ${auth}`, }, signal: AbortSignal.timeout(15000), }); if (res.status === 429) { console.warn("[defi-positions] Zerion rate limited"); return []; } if (!res.ok) { console.warn(`[defi-positions] Zerion API error: ${res.status}`); return []; } const data = await res.json() as { data?: any[] }; const positions = (data.data || []).map(normalizePosition).filter(Boolean) as DefiPosition[]; cache.set(lower, { positions, ts: Date.now() }); return positions; } catch (e) { console.warn("[defi-positions] Failed to fetch:", e); return []; } finally { inFlight.delete(lower); } })(); inFlight.set(lower, promise); return promise; } function normalizePosition(item: any): DefiPosition | null { try { const attrs = item.attributes; if (!attrs) return null; const protocol = attrs.protocol_id || attrs.protocol || "Unknown"; const posType = attrs.position_type || "deposit"; const chainRaw = attrs.chain || item.relationships?.chain?.data?.id || ""; const chainId = ZERION_CHAIN_MAP[chainRaw] || ""; const tokens: DefiPosition["tokens"] = []; let totalValueUSD = 0; // Fungible positions — may have a single fungible_info or multiple if (attrs.fungible_info) { const fi = attrs.fungible_info; const amount = attrs.quantity?.float ?? 0; const value = attrs.value ?? 0; tokens.push({ symbol: fi.symbol || "???", amount, valueUSD: value, }); totalValueUSD += value; } // Interpretations — complex positions with sub-tokens if (attrs.interpretations) { for (const interp of attrs.interpretations) { if (interp.tokens) { for (const t of interp.tokens) { const tVal = t.value ?? 0; tokens.push({ symbol: t.fungible_info?.symbol || t.symbol || "???", amount: t.quantity?.float ?? 0, valueUSD: tVal, }); totalValueUSD += tVal; } } } } // Fall back to top-level value if tokens didn't capture it if (totalValueUSD === 0 && attrs.value) { totalValueUSD = attrs.value; } if (totalValueUSD < 0.01 && tokens.length === 0) return null; // Prettify protocol name const prettyProtocol = protocol .replace(/-/g, " ") .replace(/\b\w/g, (c: string) => c.toUpperCase()); return { protocol: prettyProtocol, type: posType, chain: chainRaw.replace(/-/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase()), chainId, tokens, totalValueUSD, }; } catch { return null; } }