Merge branch 'add_cryptocompare_adapter' into 'development'

feat(Rates): Add CryptoCompare adapter

See merge request GeneralProtocols/xo/stack!87
This commit is contained in:
Jim Hamill
2025-04-30 09:15:30 +00:00
2 changed files with 244 additions and 0 deletions
+1
View File
@@ -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';
@@ -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<string>;
private readonly opts: RatesCryptoCompareOptions;
// Mutable state.
// NOTE: Whether this exists indicates whether this service is running.
private refreshTimer?: ReturnType<typeof setTimeout>;
// Rates currently available.
// Format is: denominatorUnitCode -> Set<numeratorUnitCodes>
private availablePairs: Map<string, Set<string>> = new Map();
// Supported fiat currencies from CryptoCompare
private supportedFiats: Set<string> = new Set();
private constructor(
currencyCodes: Array<string>,
opts: Partial<RatesCryptoCompareOptions>,
) {
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<string> = ['BCH'],
opts: Partial<RatesCryptoCompareOptions> = {},
): RatesCryptoCompare {
return new RatesCryptoCompare(currencyCodes, opts);
}
public async start(): Promise<void> {
// 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<void> {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
public async listPairs(): Promise<Set<string>> {
// 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<string>();
// 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<void> {
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<string>());
}
// Current fiat currencies for this crypto
const currentFiats = new Set<string>();
// 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<string>;
/**
* 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<Response>;
};
// 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());