diff --git a/packages/stack/src/rates/index.ts b/packages/stack/src/rates/index.ts index ff49720..d9dd100 100644 --- a/packages/stack/src/rates/index.ts +++ b/packages/stack/src/rates/index.ts @@ -1,5 +1,6 @@ export * from './base-rates.js'; export * from './rates-bitpay.js'; export * from './rates-coinbase.js'; +export * from './rates-cryptocompare.js'; export * from './rates-composite.js'; export * from './traits.js'; diff --git a/packages/stack/src/rates/rates-cryptocompare.ts b/packages/stack/src/rates/rates-cryptocompare.ts new file mode 100644 index 0000000..e9972cd --- /dev/null +++ b/packages/stack/src/rates/rates-cryptocompare.ts @@ -0,0 +1,243 @@ +import { BaseRates } from './base-rates.js'; + +import { z } from 'zod'; + +/** + * CryptoCompareRates + * + * Implements BaseRates by calling CryptoCompare's public REST endpoints. + * CryptoCompare is CORS-friendly and works well in browser environments. + */ +export class RatesCryptoCompare extends BaseRates { + private readonly currencyCodes: Array; + private readonly opts: RatesCryptoCompareOptions; + + // Mutable state. + // NOTE: Whether this exists indicates whether this service is running. + private refreshTimer?: ReturnType; + + // Rates currently available. + // Format is: denominatorUnitCode -> Set + private availablePairs: Map> = new Map(); + + // Supported fiat currencies from CryptoCompare + private supportedFiats: Set = new Set(); + + private constructor( + currencyCodes: Array, + opts: Partial, + ) { + super(); + + this.currencyCodes = currencyCodes; + this.opts = { + // Default to CryptoCompare API URL. + baseURL: 'https://min-api.cryptocompare.com/data', + // Default to 60 second refresh. + refreshIntervalMs: 60_000, + // Default list of target currencies if none specified + targetFiats: ['USD', 'EUR', 'GBP', 'JPY', 'CNY', 'AUD', 'CAD', 'CHF'], + // Default to browser/node fetch function + fetch: (...args) => globalThis.fetch(...args), + // API key is optional but recommended + apiKey: undefined, + // Merge any user-specified options in. + ...opts, + }; + + // Initialize the available pairs map + this.availablePairs = new Map(); + + // Initialize supported fiats with the default/specified target fiats + this.supportedFiats = new Set( + this.opts.targetFiats.map((fiat) => fiat.toUpperCase()), + ); + } + + /** + * Factory to create a CryptoCompareRates instance. + */ + public static from( + currencyCodes: Array = ['BCH'], + opts: Partial = {}, + ): RatesCryptoCompare { + return new RatesCryptoCompare(currencyCodes, opts); + } + + public async start(): Promise { + // Initialize by getting supported fiat currencies + await this.getSupportedCurrencies(); + + // Initial refresh to populate available pairs + await this.refreshPrices(); + + // Set a timer to refresh prices at the given interval. + this.refreshTimer = setTimeout( + this.refreshPrices.bind(this), + this.opts.refreshIntervalMs, + ); + } + + public async stop(): Promise { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + + public async listPairs(): Promise> { + // Refresh prices first to make sure that we have an accurate picture of supported pairs. + if (!this.refreshTimer) { + await this.refreshPrices(); + } + + const formattedPairs = new Set(); + + // Convert our internal representation to the standard "NUMERATOR/DENOMINATOR" format + for (const [denominator, numerators] of this.availablePairs.entries()) { + for (const numerator of numerators) { + formattedPairs.add(`${numerator}/${denominator}`); + } + } + + return formattedPairs; + } + + private async getSupportedCurrencies(): Promise { + try { + // CryptoCompare has an endpoint to list all supported currencies + const endpoint = `${this.opts.baseURL}/all/coinlist`; + + // Prepare headers if API key is provided + const headers: HeadersInit = {}; + if (this.opts.apiKey) { + headers['authorization'] = `Apikey ${this.opts.apiKey}`; + } + + const response = await this.opts.fetch(endpoint, { headers }); + + if (!response.ok) { + throw new Error( + `Failed to fetch supported currencies: ${response.statusText}`, + ); + } + + const data = await response.json(); + + // CryptoCompare returns a complex object, verify that Data property exists + if (data.Response === 'Success' && data.Data) { + // We've successfully retrieved the currency list + // This is primarily to confirm our crypto currencies exist + // but we don't need to do anything with this data right now + } + } catch (error) { + // Log error but continue - we'll use default target fiats + console.error('Error fetching supported currencies:', error); + } + } + + private async refreshPrices() { + // For each crypto we're interested in, get prices for all target fiats in a single call + for (const cryptoCode of this.currencyCodes) { + try { + // The fsyms parameter is the crypto code(s) + // The tsyms parameter is a comma-separated list of all target fiat currencies + const endpoint = `${this.opts.baseURL}/price?fsym=${cryptoCode.toUpperCase()}&tsyms=${Array.from( + this.supportedFiats, + ).join(',')}`; + + // Prepare headers if API key is provided + const headers: HeadersInit = {}; + if (this.opts.apiKey) { + headers['authorization'] = `Apikey ${this.opts.apiKey}`; + } + + // Fetch from CryptoCompare API + const pricesRes = await this.opts.fetch(endpoint, { headers }); + + // If request failed, throw an error + if (!pricesRes.ok) { + throw new Error( + `Failed to fetch rates for ${cryptoCode}: ${pricesRes.statusText}`, + ); + } + + // Extract the data - CryptoCompare returns a simple object with fiat codes as keys + const data = CryptoComparePriceSchema.parse(await pricesRes.json()); + + // Initialize the set for this crypto if it doesn't exist + if (!this.availablePairs.has(cryptoCode)) { + this.availablePairs.set(cryptoCode, new Set()); + } + + // Current fiat currencies for this crypto + const currentFiats = new Set(); + + // Process each rate entry + for (const [fiatCode, rateValue] of Object.entries(data)) { + const upperFiatCode = fiatCode.toUpperCase(); + + // Add this pair to our available pairs + currentFiats.add(upperFiatCode); + + // Emit an event to indicate this rate has been updated + this.emit('rateUpdated', { + denominatorUnitCode: cryptoCode, + numeratorUnitCode: upperFiatCode, + price: rateValue, + }); + } + + // Update the available pairs for this crypto + this.availablePairs.set(cryptoCode, currentFiats); + } catch (error) { + // Log the error for debugging + console.error(`Error refreshing ${cryptoCode} prices:`, error); + + // This pair is no longer available, so delete it from available pairs + this.availablePairs.delete(cryptoCode); + } + } + + // Schedule the next refresh + if (this.refreshTimer) { + this.refreshTimer = setTimeout( + this.refreshPrices.bind(this), + this.opts.refreshIntervalMs, + ); + } + } +} + +/** + * Options for RatesCryptoCompare. + */ +export type RatesCryptoCompareOptions = { + /** + * Base URL for CryptoCompare API. + */ + baseURL: string; + + /** + * Refresh interval in milliseconds. + */ + refreshIntervalMs: number; + + /** + * List of target fiat currencies to fetch prices for. + */ + targetFiats: Array; + + /** + * Optional API key for CryptoCompare (increases rate limits) + */ + apiKey?: string; + + /** + * Optional fetch-like function (e.g. global fetch) for HTTP calls. + */ + fetch: (url: string, options?: RequestInit) => Promise; +}; + +// Schema for the price response from CryptoCompare +// This is a simple object with fiat currency codes as keys +// and price values as numbers +const CryptoComparePriceSchema = z.record(z.string(), z.number());