import { CRYPTO_ASSETS } from "@/lib/assets"; import type { MarketChartRange } from "@/lib/market"; export interface CryptoRateResult { usdPrices: Record; updatedAt: string; } export interface CryptoMarketSnapshot { priceUsd: number; change24hPct: number | null; marketCapUsd: number | null; volume24hUsd: number | null; updatedAt: string; } export interface CryptoPricePoint { timestamp: string; priceUsd: number; } const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; const COINGECKO_RANGE_TO_DAYS: Record = { "24h": "1", "7d": "7", "30d": "30", "1y": "365", all: "max", }; function buildHistoryUrls(providerId: string, range: MarketChartRange): string[] { const encodedId = encodeURIComponent(providerId); const days = COINGECKO_RANGE_TO_DAYS[range]; if (range === "all") { const nowInSeconds = Math.floor(Date.now() / 1000); const tenYearsAgoInSeconds = nowInSeconds - 60 * 60 * 24 * 365 * 10; return [ `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=max`, `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=max&interval=daily`, `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=3650`, `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=3650&interval=daily`, `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart/range?vs_currency=usd&from=${tenYearsAgoInSeconds}&to=${nowInSeconds}`, ]; } if (range === "1y") { return [ `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=${encodeURIComponent(days)}&interval=daily`, ]; } return [ `${COINGECKO_BASE_URL}/coins/${encodedId}/market_chart?vs_currency=usd&days=${encodeURIComponent(days)}`, ]; } function buildCoinGeckoHeaders(): HeadersInit { const headers: Record = { accept: "application/json" }; const proApiKey = process.env.COINGECKO_PRO_API_KEY?.trim(); const demoApiKey = process.env.COINGECKO_DEMO_API_KEY?.trim(); const genericApiKey = process.env.COINGECKO_API_KEY?.trim(); const genericApiKeyType = process.env.COINGECKO_API_KEY_TYPE?.trim().toLowerCase() ?? "demo"; if (proApiKey) { headers["x-cg-pro-api-key"] = proApiKey; return headers; } if (demoApiKey) { headers["x-cg-demo-api-key"] = demoApiKey; return headers; } if (genericApiKey) { if (genericApiKeyType === "pro") { headers["x-cg-pro-api-key"] = genericApiKey; } else { headers["x-cg-demo-api-key"] = genericApiKey; } } return headers; } export async function fetchCryptoMarketSnapshot( providerId: string, ): Promise { const url = `${COINGECKO_BASE_URL}/coins/markets?vs_currency=usd&ids=${encodeURIComponent( providerId, )}&price_change_percentage=24h`; const response = await fetch(url, { next: { revalidate: 60 }, headers: buildCoinGeckoHeaders(), }); if (!response.ok) { throw new Error(`Unable to load crypto market data (${response.status})`); } const payload = (await response.json()) as Array<{ current_price?: number; market_cap?: number; total_volume?: number; price_change_percentage_24h?: number; last_updated?: string; }>; const market = payload[0]; if (!market?.current_price || market.current_price <= 0) { throw new Error("Crypto market data is unavailable"); } return { priceUsd: market.current_price, change24hPct: typeof market.price_change_percentage_24h === "number" ? market.price_change_percentage_24h : null, marketCapUsd: typeof market.market_cap === "number" ? market.market_cap : null, volume24hUsd: typeof market.total_volume === "number" ? market.total_volume : null, updatedAt: market.last_updated ?? new Date().toISOString(), }; } export async function fetchCryptoPriceHistory( providerId: string, range: MarketChartRange, ): Promise { const urls = buildHistoryUrls(providerId, range); let lastStatus: number | null = null; for (const url of urls) { const response = await fetch(url, { next: { revalidate: 60 }, headers: buildCoinGeckoHeaders(), }); if (!response.ok) { lastStatus = response.status; continue; } const payload = (await response.json()) as { prices?: Array<[number, number]>; }; const points = (payload.prices ?? []) .filter( (entry): entry is [number, number] => Array.isArray(entry) && entry.length >= 2 && Number.isFinite(entry[0]) && Number.isFinite(entry[1]) && entry[1] >= 0, ) .map(([timestamp, priceUsd]) => ({ timestamp: new Date(timestamp).toISOString(), priceUsd, })); if (points.length > 1) { return points; } } if (lastStatus !== null) { throw new Error(`Unable to load crypto price history (${lastStatus})`); } throw new Error("Crypto price history is unavailable"); } export async function fetchCryptoData(): Promise { const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter( (id): id is string => Boolean(id) ); const url = `${COINGECKO_BASE_URL}/simple/price?ids=${encodeURIComponent( ids.join(",") )}&vs_currencies=usd&include_last_updated_at=true`; const response = await fetch(url, { next: { revalidate: 60 }, headers: buildCoinGeckoHeaders() }); if (!response.ok) { throw new Error(`Unable to load crypto rates (${response.status})`); } const payload = (await response.json()) as Record< string, { usd?: number; last_updated_at?: number; } >; const usdPrices: Record = {}; let mostRecentUpdate = 0; for (const asset of CRYPTO_ASSETS) { if (!asset.providerId) { continue; } const entry = payload[asset.providerId]; if (!entry?.usd || entry.usd <= 0) { continue; } usdPrices[asset.code] = entry.usd; if (entry.last_updated_at && entry.last_updated_at > mostRecentUpdate) { mostRecentUpdate = entry.last_updated_at; } } return { usdPrices, updatedAt: mostRecentUpdate > 0 ? new Date(mostRecentUpdate * 1000).toISOString() : new Date().toISOString() }; }