import type { Asset } from './types'; import { parseDecimalToUnits } from './amounts'; import { RATE_DECIMALS } from './peg'; const COINGECKO_IDS: Record = { USDT: 'tether', USDC: 'usd-coin' }; export interface RateQuote { /** Fiat per 1 whole token, scaled by 1e6 (see RATE_DECIMALS). */ rateUnits: bigint; source: string; at: Date; } export interface RateServiceOptions { fetchFn?: typeof fetch; /** Cache TTL, default 60s per the spec. */ ttlMs?: number; now?: () => number; baseUrl?: string; } /** Convert a JSON price number into integer rate units without float arithmetic on money paths. */ export function priceToRateUnits(price: number): bigint { if (!Number.isFinite(price) || price <= 0) throw new Error(`invalid price: ${price}`); let text = String(price); if (/e/i.test(text)) text = price.toFixed(RATE_DECIMALS + 2); const [whole = '0', frac = ''] = text.split('.'); const truncatedFrac = frac.slice(0, RATE_DECIMALS); return parseDecimalToUnits(truncatedFrac ? `${whole}.${truncatedFrac}` : whole, RATE_DECIMALS); } export class RateService { private readonly fetchFn: typeof fetch; private readonly ttlMs: number; private readonly now: () => number; private readonly baseUrl: string; private readonly cache = new Map(); constructor(options: RateServiceOptions = {}) { this.fetchFn = options.fetchFn ?? fetch; this.ttlMs = options.ttlMs ?? 60_000; this.now = options.now ?? Date.now; this.baseUrl = options.baseUrl ?? 'https://api.coingecko.com/api/v3'; } async getRate(asset: Asset, fiatCurrency: string): Promise { const fiat = fiatCurrency.toLowerCase(); const key = `${asset}:${fiat}`; const nowMs = this.now(); const cached = this.cache.get(key); if (cached && nowMs - cached.fetchedAt < this.ttlMs) return cached.quote; const id = COINGECKO_IDS[asset]; const url = `${this.baseUrl}/simple/price?ids=${id}&vs_currencies=${encodeURIComponent(fiat)}`; const res = await this.fetchFn(url, { headers: { accept: 'application/json' } }); if (!res.ok) throw new Error(`rate provider returned HTTP ${res.status}`); const body = (await res.json()) as Record>; const price = body?.[id]?.[fiat]; if (typeof price !== 'number' || !(price > 0)) { throw new Error(`rate provider returned no ${fiatCurrency} price for ${asset}`); } const quote: RateQuote = { rateUnits: priceToRateUnits(price), source: 'coingecko', at: new Date(nowMs) }; this.cache.set(key, { fetchedAt: nowMs, quote }); return quote; } }