import type { CurrencyProvider, RateFetchOptions, RateFetchResult, } from "@suaveplan/currency"; import { CURRENCY_PROVIDER_CODES, CurrencyProviderError, httpRequest, type NoKeyConfig, } from "@suaveplan/currency-providers"; import type { FetchLike } from "@suaveplan/types"; import { COIN_IDS, getCoinGeckoId } from "../coin-ids/coin-ids.js"; const PROVIDER_NAME = "coingecko"; const DEFAULT_BASE_URL = "https://api.coingecko.com/api/v3"; const PRO_API_KEY_HEADER = "x-cg-pro-api-key"; const ALL_IDS = Object.values(COIN_IDS).join(","); /** * Configuration accepted by {@link CoinGeckoProvider}. Extends the shared * `NoKeyConfig` (optional `baseUrl` + injectable `fetch`) and adds the * optional Pro-tier `apiKey`. Both the free public tier and the Pro tier * use the same endpoint shape, so the key is genuinely optional rather * than mutually exclusive — omitting it issues a keyless request, * supplying it attaches the `x-cg-pro-api-key` header. */ export interface CoinGeckoConfig extends NoKeyConfig { /** * Optional CoinGecko Pro API key. When supplied, every outbound * request carries `x-cg-pro-api-key: `. Absent → free public * tier (10–30 calls/min, no key). */ readonly apiKey?: string; } type CoinGeckoResponseShape = Record>; function isResponseShape(value: unknown): value is CoinGeckoResponseShape { if (typeof value !== "object" || value === null || Array.isArray(value)) { return false; } for (const inner of Object.values(value)) { if ( typeof inner !== "object" || inner === null || Array.isArray(inner) ) { return false; } } return true; } function assertTargetsKnown(targets: readonly string[] | undefined): void { if (!targets) return; for (const target of targets) { if (getCoinGeckoId(target) === undefined) { throw new CurrencyProviderError( CURRENCY_PROVIDER_CODES.UNKNOWN_CURRENCY, `${PROVIDER_NAME}: no CoinGecko id for target symbol "${target}"`, { providerName: PROVIDER_NAME } ); } } } async function parseJsonBody(response: Response): Promise { try { return await response.json(); } catch (cause) { throw new CurrencyProviderError( CURRENCY_PROVIDER_CODES.PARSE_ERROR, `${PROVIDER_NAME}: failed to parse response body as JSON`, { providerName: PROVIDER_NAME, cause: cause instanceof Error ? cause : undefined, } ); } } function buildRates( data: CoinGeckoResponseShape, vsCurrency: string, targets: readonly string[] | undefined ): Record { const idToSymbol = new Map(); for (const [symbol, id] of Object.entries(COIN_IDS)) { idToSymbol.set(id, symbol); } const allRates: Record = {}; for (const [id, prices] of Object.entries(data)) { const symbol = idToSymbol.get(id); const price = prices[vsCurrency]; if (symbol !== undefined && typeof price === "number" && price > 0) { allRates[symbol] = 1 / price; } } if (!targets) return allRates; const filtered: Record = {}; for (const target of targets) { const value = allRates[target]; if (value !== undefined) { filtered[target] = value; } } return filtered; } /** * CoinGecko crypto-rate provider. Implements the `CurrencyProvider` * contract from `@suaveplan/currency` against the public `/simple/price` * endpoint, routes every request through the shared `httpRequest` * helper from `@suaveplan/currency-providers`, and raises typed * `CurrencyProviderError` on every failure path. * * Both free and Pro tiers share the same URL shape; passing `apiKey` * attaches the `x-cg-pro-api-key` header, omitting it issues a keyless * public-tier request. */ export class CoinGeckoProvider implements CurrencyProvider { public readonly name = PROVIDER_NAME; public readonly supportsCrypto = true; public readonly supportsFiat = true; private readonly baseUrl: string; private readonly fetchImpl: FetchLike; private readonly headers: Readonly> | undefined; public constructor(config: CoinGeckoConfig = {}) { this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; this.fetchImpl = config.fetch ?? (globalThis.fetch as FetchLike); this.headers = config.apiKey !== undefined ? Object.freeze({ [PRO_API_KEY_HEADER]: config.apiKey }) : undefined; } public async fetchRates( options?: RateFetchOptions ): Promise { const base = options?.base ?? "USD"; const vsCurrency = base.toLowerCase(); assertTargetsKnown(options?.targets); const url = `${this.baseUrl}/simple/price?ids=${ALL_IDS}&vs_currencies=${vsCurrency}`; const init: RequestInit | undefined = this.headers !== undefined ? { headers: { ...this.headers } } : undefined; const response = await httpRequest({ url, fetch: this.fetchImpl, providerName: PROVIDER_NAME, ...(init !== undefined ? { init } : {}), ...(options?.signal !== undefined ? { signal: options.signal } : {}), }); const body = await parseJsonBody(response); if (!isResponseShape(body)) { throw new CurrencyProviderError( CURRENCY_PROVIDER_CODES.UPSTREAM_ERROR, `${PROVIDER_NAME}: response body did not match Record>`, { providerName: PROVIDER_NAME } ); } return { base, rates: buildRates(body, vsCurrency, options?.targets), timestamp: Date.now(), }; } }