Merge branch 'rates-traits-coinbase-bitpay' into 'development'
Refactor rates to use traits for historical data and be more event based. Add Bitpay. See merge request GeneralProtocols/xo/stack!85
This commit is contained in:
@@ -1,62 +1,38 @@
|
||||
import type { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import { EventEmitter } from '../utils/index.js';
|
||||
|
||||
export type RateOptions = {
|
||||
// minimum timestamp to use for the rate
|
||||
minTimestamp: number;
|
||||
|
||||
// maximum timestamp to use for the rate
|
||||
maxTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by our Rates Adapters
|
||||
*/
|
||||
export type RatesEventMap = {
|
||||
'price-updated': {
|
||||
currency: string;
|
||||
rateUpdated: {
|
||||
numeratorUnitCode: string;
|
||||
denominatorUnitCode: string;
|
||||
price: number;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export abstract class BaseRates extends EventEmitter<RatesEventMap> {
|
||||
/** Starts the given rates adapter so that it will emit events on price updates. */
|
||||
public abstract start(): Promise<void>;
|
||||
|
||||
/** Stops the given rates adapter so that it will stop checking for price updates. */
|
||||
public abstract stop(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Convert an amount in a source currency to BCH.
|
||||
*
|
||||
* @param {number} amount - The amount to convert.
|
||||
* @param {string} fromCurrency - The source currency.
|
||||
*
|
||||
* @returns The converted amount in BCH.
|
||||
* List all available market products (pairs).
|
||||
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
|
||||
*/
|
||||
public abstract toBCH(amount: number, fromCurrency: string, options?: Partial<RateOptions>): Promise<Satoshis>;
|
||||
|
||||
/**
|
||||
* Convert an amount in BCH to a target currency.
|
||||
*
|
||||
* @param {Satoshis} amount - The amount to convert.
|
||||
* @param {string} targetCurrency - The target currency.
|
||||
*
|
||||
* @returns The converted amount in the target currency.
|
||||
*/
|
||||
public abstract fromBCH(amount: Satoshis, targetCurrency: string, options?: Partial<RateOptions>): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get the raw exchange rate for a currency pair.
|
||||
*
|
||||
* @param {string} currency - The currency to get the rate for.
|
||||
*
|
||||
* @returns The raw exchange rate for the currency.
|
||||
*/
|
||||
public abstract getRate(currency: string, options?: Partial<RateOptions>): Promise<number>;
|
||||
public abstract listPairs(): Promise<Set<string>>;
|
||||
|
||||
// TODO: Consider whether we actually want the below.
|
||||
// Ideally, we will want to replace this with something like the Units class:
|
||||
// See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44
|
||||
/**
|
||||
* Format the amount in the target currency to the correct number of decimal places.
|
||||
*
|
||||
*
|
||||
* @param {number} amount - The amount to format.
|
||||
* @param {string} targetCurrency - The target currency.
|
||||
*
|
||||
*
|
||||
* @returns The formatted amount.
|
||||
*/
|
||||
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './base-rates.js';
|
||||
export * from './rates-composite.js';
|
||||
export * from './rates-bitpay.js';
|
||||
export * from './rates-coinbase.js';
|
||||
export * from './rates-composite.js';
|
||||
export * from './traits.js';
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { BaseRates } from './base-rates.js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* BitPayRates
|
||||
*
|
||||
* Implements BaseRates by calling BitPay's public REST endpoints.
|
||||
*/
|
||||
export class RatesBitPay extends BaseRates {
|
||||
private readonly currencyCodes: Array<string>;
|
||||
private readonly opts: RatesBitPayOptions;
|
||||
|
||||
// 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();
|
||||
|
||||
private constructor(
|
||||
currencyCodes: Array<string>,
|
||||
opts: Partial<RatesBitPayOptions>,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.currencyCodes = currencyCodes;
|
||||
this.opts = {
|
||||
// Default to BitPay API URL.
|
||||
baseURL: 'https://bitpay.com/api',
|
||||
// Default to 60 second refresh.
|
||||
refreshIntervalMs: 60_000,
|
||||
// Default to browser/node fetch function
|
||||
fetch: fetch,
|
||||
// Merge any user-specified options in.
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Initialize the available pairs map
|
||||
this.availablePairs = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create a BitPayRates instance.
|
||||
*/
|
||||
public static from(
|
||||
currencyCodes: Array<string> = ['BCH'],
|
||||
opts: Partial<RatesBitPayOptions> = {},
|
||||
): RatesBitPay {
|
||||
return new RatesBitPay(currencyCodes, opts);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
// 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 refreshPrices() {
|
||||
// Process each cryptocurrency we're interested in
|
||||
for (const cryptoCode of this.currencyCodes) {
|
||||
try {
|
||||
// BitPay provides rates for each crypto at a specific endpoint
|
||||
// For BTC: /api/rates
|
||||
// For others: /api/rates/{cryptoCode}
|
||||
const endpoint =
|
||||
cryptoCode.toUpperCase() === 'BTC'
|
||||
? `${this.opts.baseURL}/rates`
|
||||
: `${this.opts.baseURL}/rates/${cryptoCode.toLowerCase()}`;
|
||||
|
||||
// Fetch from BitPay API
|
||||
const ratesRes = await this.opts.fetch(endpoint, {});
|
||||
|
||||
// If request failed, throw an error
|
||||
if (!ratesRes.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch rates for ${cryptoCode}: ${ratesRes.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the data
|
||||
const data = RatesBitPayPayloadSchema.parse(await ratesRes.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 rate of data) {
|
||||
const fiatCode = rate.code;
|
||||
const rateValue = rate.rate;
|
||||
|
||||
// Add this pair to our available pairs
|
||||
currentFiats.add(fiatCode);
|
||||
|
||||
// Emit an event to indicate this rate has been updated
|
||||
// BitPay returns how much 1 unit of crypto is worth in the fiat currency
|
||||
this.emit('rateUpdated', {
|
||||
denominatorUnitCode: cryptoCode,
|
||||
numeratorUnitCode: fiatCode,
|
||||
price: rateValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the available pairs for this crypto
|
||||
this.availablePairs.set(cryptoCode, currentFiats);
|
||||
} catch (_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 RatesBitPay.
|
||||
*/
|
||||
export type RatesBitPayOptions = {
|
||||
/**
|
||||
* Base URL for BitPay API (defaults to prod).
|
||||
*/
|
||||
baseURL: string;
|
||||
|
||||
/**
|
||||
* Refresh interval in milliseconds.
|
||||
*/
|
||||
refreshIntervalMs: number;
|
||||
|
||||
/**
|
||||
* Optional fetch-like function (e.g. global fetch) for HTTP calls.
|
||||
*/
|
||||
fetch: (url: string, options: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
// The rates payload schema we receive from BitPay's API.
|
||||
// BitPay returns an array of objects, each with a code and rate property
|
||||
const RatesBitPayPayloadSchema = z.array(
|
||||
z.object({
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
rate: z.number(),
|
||||
}),
|
||||
);
|
||||
@@ -1,45 +1,6 @@
|
||||
import { Satoshis } from '@xocash/primitives';
|
||||
import { BaseRates } from './base-rates.js';
|
||||
|
||||
import { BaseRates, type RateOptions } from './base-rates.js';
|
||||
|
||||
import { Memoizer } from '../utils/index.js';
|
||||
|
||||
/**
|
||||
* Represents a single market product (pair) on Coinbase Exchange.
|
||||
*/
|
||||
export interface CoinbaseProduct {
|
||||
id: string;
|
||||
base_currency: string;
|
||||
quote_currency: string;
|
||||
base_min_size: string;
|
||||
base_max_size: string;
|
||||
quote_increment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies for RatesCoinbase.
|
||||
*/
|
||||
export type RatesCoinbaseDependencies = {
|
||||
/**
|
||||
* Base URL for Coinbase Exchange API (defaults to prod).
|
||||
*/
|
||||
apiBaseUrl: string;
|
||||
|
||||
/**
|
||||
* Optional cache instance for rate lookups.
|
||||
*/
|
||||
cache: Memoizer;
|
||||
|
||||
/**
|
||||
* Optional cache TTL for rate lookups.
|
||||
*/
|
||||
cacheTTL: number;
|
||||
|
||||
/**
|
||||
* Optional fetch-like function (e.g. global fetch) for HTTP calls.
|
||||
*/
|
||||
fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* CoinbaseRates
|
||||
@@ -47,110 +8,168 @@ export type RatesCoinbaseDependencies = {
|
||||
* Implements BaseRates by calling Coinbase Exchange's public REST endpoints.
|
||||
*/
|
||||
export class RatesCoinbase extends BaseRates {
|
||||
private apiBaseUrl: string;
|
||||
private cache: Memoizer;
|
||||
private cacheTTL: number;
|
||||
private fetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
private readonly currencyCodes: Array<string>;
|
||||
private readonly opts: RatesCoinbaseOptions;
|
||||
|
||||
private constructor(deps: Partial<RatesCoinbaseDependencies>) {
|
||||
// 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();
|
||||
|
||||
private constructor(
|
||||
currencyCodes: Array<string>,
|
||||
opts: Partial<RatesCoinbaseOptions>,
|
||||
) {
|
||||
super();
|
||||
this.apiBaseUrl = deps.apiBaseUrl ?? 'https://api.exchange.coinbase.com';
|
||||
this.cache = deps.cache ?? new Memoizer();
|
||||
this.cacheTTL = deps.cacheTTL ?? 60 * 1000;
|
||||
this.fetch = deps.fetch ?? fetch.bind(globalThis);
|
||||
|
||||
this.currencyCodes = currencyCodes;
|
||||
this.opts = {
|
||||
// Default to Coinbase API URL.
|
||||
baseURL: 'https://api.coinbase.com',
|
||||
// Default to 60 second refresh.
|
||||
refreshIntervalMs: 60_000,
|
||||
// Default to browser/node fetch function
|
||||
fetch: fetch,
|
||||
// Merge any user-specified options in.
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Initialize the available pairs map
|
||||
this.availablePairs = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create a CoinbaseRates instance.
|
||||
*/
|
||||
public static from(
|
||||
apiBaseUrl: string = 'https://api.exchange.coinbase.com',
|
||||
cacheTTL: number = 60 * 1000,
|
||||
fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>,
|
||||
currencyCodes: Array<string> = ['BCH'],
|
||||
opts: Partial<RatesCoinbaseOptions> = {},
|
||||
): RatesCoinbase {
|
||||
const cache = new Memoizer();
|
||||
return new RatesCoinbase({ apiBaseUrl, cache, cacheTTL, fetch });
|
||||
return new RatesCoinbase(currencyCodes, opts);
|
||||
}
|
||||
|
||||
/** No persistent connection to open. */
|
||||
public async start(): Promise<void> {}
|
||||
public async start(): Promise<void> {
|
||||
// Initial refresh to populate available pairs
|
||||
await this.refreshPrices();
|
||||
|
||||
/** No persistent connection to close. */
|
||||
public async stop(): Promise<void> {}
|
||||
// Set a timer to refresh prices at the given interval.
|
||||
this.refreshTimer = setTimeout(
|
||||
this.refreshPrices.bind(this),
|
||||
this.opts.refreshIntervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available market products (pairs).
|
||||
*/
|
||||
public async listProducts(): Promise<CoinbaseProduct[]> {
|
||||
const url = `${this.apiBaseUrl}/products`;
|
||||
const res = await this.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch products (${res.status}): ${res.statusText}`);
|
||||
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();
|
||||
}
|
||||
return (await res.json()) as CoinbaseProduct[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw exchange rate for a currency pair.
|
||||
*/
|
||||
public async getRate(
|
||||
currency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<number> {
|
||||
const fetchPair = async (pair: string): Promise<number> => {
|
||||
return await this.cache.get(pair, async () => {
|
||||
const price = await this.fetchTickerPrice(pair);
|
||||
this.cache.set(pair, price);
|
||||
return price;
|
||||
}, this.cacheTTL);
|
||||
};
|
||||
const formattedPairs = new Set<string>();
|
||||
|
||||
const direct = `${currency}-BCH`;
|
||||
try {
|
||||
const denominatorRate = await fetchPair(direct);
|
||||
return 1 / denominatorRate;
|
||||
} catch {
|
||||
const inverse = `BCH-${currency}`;
|
||||
const invPrice = await fetchPair(inverse);
|
||||
return invPrice;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an amount in a foreign currency to BCH.
|
||||
*/
|
||||
public async toBCH(
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<Satoshis> {
|
||||
const rate = await this.getRate(fromCurrency, options);
|
||||
return Satoshis.fromBCH(amount / rate);
|
||||
}
|
||||
private async refreshPrices() {
|
||||
// Iterate through each of the currency codes.
|
||||
for (const currencyCode of this.currencyCodes) {
|
||||
// Initialize the set for this currency if it doesn't exist
|
||||
if (!this.availablePairs.has(currencyCode)) {
|
||||
this.availablePairs.set(currencyCode, new Set<string>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BCH amount to a target currency.
|
||||
*/
|
||||
public async fromBCH(
|
||||
amount: Satoshis,
|
||||
targetCurrency: string,
|
||||
options?: Partial<RateOptions>,
|
||||
): Promise<number> {
|
||||
const bchAmount = amount.toBCH();
|
||||
const rate = await this.getRate(targetCurrency, options);
|
||||
return bchAmount * rate;
|
||||
}
|
||||
// Current numerators for this denominator, to track which ones are still available
|
||||
const currentNumerators = new Set<string>();
|
||||
|
||||
/**
|
||||
* Fetch the current ticker price for a given pair.
|
||||
*/
|
||||
private async fetchTickerPrice(pair: string): Promise<number> {
|
||||
const url = `${this.apiBaseUrl}/products/${pair}/ticker`;
|
||||
const res = await this.fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ticker API error (${res.status}): ${res.statusText}`);
|
||||
// Attempt to fetch and process the currency code.
|
||||
try {
|
||||
// Fetch from Coinbase API.
|
||||
const priceRes = await this.opts.fetch(
|
||||
`${this.opts.baseURL}/v2/exchange-rates?currency=${currencyCode}`,
|
||||
{},
|
||||
);
|
||||
|
||||
// If request failed, throw an error.
|
||||
if (!priceRes.ok) {
|
||||
throw new Error(`Failed to fetch rates: ${priceRes.statusText}`);
|
||||
}
|
||||
|
||||
// Extract the data.
|
||||
const data = RatesCoinbasePayloadSchema.parse(await priceRes.json());
|
||||
|
||||
// Iterate over each currency and emit an event.
|
||||
for (const [numeratorUnitCode, rate] of Object.entries(
|
||||
data.data.rates,
|
||||
)) {
|
||||
// Add this pair to our available pairs
|
||||
currentNumerators.add(numeratorUnitCode);
|
||||
|
||||
// Emit an event to indicate this rate has been updated.
|
||||
this.emit('rateUpdated', {
|
||||
denominatorUnitCode: currencyCode,
|
||||
numeratorUnitCode: numeratorUnitCode,
|
||||
price: Number(rate),
|
||||
});
|
||||
}
|
||||
|
||||
// Update the available pairs for this denominator
|
||||
this.availablePairs.set(currencyCode, currentNumerators);
|
||||
} catch (error) {
|
||||
// This pair is no longer available, so delete it from available pairs
|
||||
this.availablePairs.delete(currencyCode);
|
||||
console.error(`Error refreshing rates for ${currencyCode}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the next refresh
|
||||
if (this.refreshTimer) {
|
||||
this.refreshTimer = setTimeout(
|
||||
this.refreshPrices.bind(this),
|
||||
this.opts.refreshIntervalMs,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { price: string };
|
||||
return parseFloat(data.price);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for RatesCoinbase.
|
||||
*/
|
||||
export type RatesCoinbaseOptions = {
|
||||
/**
|
||||
* Base URL for Coinbase Exchange API (defaults to prod).
|
||||
*/
|
||||
baseURL: string;
|
||||
|
||||
/**
|
||||
* Refresh interval in milliseconds.
|
||||
*/
|
||||
refreshIntervalMs: number;
|
||||
|
||||
/**
|
||||
* Optional fetch-like function (e.g. global fetch) for HTTP calls.
|
||||
*/
|
||||
fetch: (url: string, options: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
// The rates payload schema we receive from Coinbase's API.
|
||||
const RatesCoinbasePayloadSchema = z.object({
|
||||
data: z.object({
|
||||
currency: z.string(),
|
||||
rates: z.record(z.string(), z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,43 +1,253 @@
|
||||
import { Satoshis } from "@xocash/primitives";
|
||||
|
||||
import { BaseRates, type RateOptions } from "./base-rates.js";
|
||||
|
||||
export type RatesCompositePolicy = (rates: number[]) => number;
|
||||
|
||||
const ratesAveragePolicy: RatesCompositePolicy = (rates) => rates.reduce((acc, rate) => acc + rate, 0) / rates.length;
|
||||
import { BaseRates } from './base-rates.js';
|
||||
|
||||
/**
|
||||
* A composite rate that averages the rates of multiple rates.
|
||||
* A composite rate adapter that combines rates from multiple rate sources
|
||||
* using a configurable policy.
|
||||
*/
|
||||
export class RatesComposite extends BaseRates {
|
||||
constructor(private rates: BaseRates[], private policy: RatesCompositePolicy = ratesAveragePolicy) {
|
||||
// Track available pairs across all rate adapters
|
||||
private availablePairs: Set<string> = new Set();
|
||||
|
||||
// Cache of the latest rates from each adapter
|
||||
private rateCache: Map<string, Map<string, Map<number, number>>> = new Map();
|
||||
|
||||
// Off callbacks for unsubscribing from events
|
||||
private unsubscribeFunctions: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* Creates a new RatesComposite instance
|
||||
*
|
||||
* @param adapters Array of BaseRates adapters to combine
|
||||
* @param policy Policy to determine how to combine rates (defaults to average)
|
||||
* @param refreshIntervalMs How often to emit combined rates (defaults to 1000ms)
|
||||
*/
|
||||
constructor(
|
||||
private adapters: BaseRates[],
|
||||
private policy: RatesCompositePolicy = ratesAveragePolicy,
|
||||
private refreshIntervalMs: number = 1000,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Initialize rate cache
|
||||
this.rateCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all rate adapters and setup event listeners
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.start()));
|
||||
// Start all adapters
|
||||
await Promise.all(this.adapters.map((adapter) => adapter.start()));
|
||||
|
||||
// Setup event listeners for all adapters
|
||||
for (let i = 0; i < this.adapters.length; i++) {
|
||||
const adapter = this.adapters[i];
|
||||
const adapterIndex = i;
|
||||
|
||||
const listener = (event: {
|
||||
denominatorUnitCode: string;
|
||||
numeratorUnitCode: string;
|
||||
price: number;
|
||||
}) => {
|
||||
this.handleRateUpdate(adapterIndex, event);
|
||||
};
|
||||
|
||||
// Use the returned unsubscribe function from the custom EventEmitter
|
||||
const unsubscribe = adapter.on('rateUpdated', listener);
|
||||
|
||||
// Store unsubscribe function for cleanup
|
||||
this.unsubscribeFunctions.push(unsubscribe);
|
||||
}
|
||||
|
||||
// Start periodic refresh to emit combined rates
|
||||
this.startPeriodicRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all rate adapters and clean up event listeners
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await Promise.all(this.rates.map((rate) => rate.stop()));
|
||||
// Stop periodic refresh
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
|
||||
// Clean up all event listeners by calling their unsubscribe functions
|
||||
for (const unsubscribe of this.unsubscribeFunctions) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.unsubscribeFunctions = [];
|
||||
|
||||
// Stop all adapters
|
||||
await Promise.all(this.adapters.map((adapter) => adapter.stop()));
|
||||
}
|
||||
|
||||
public async getRate(currency: string, options?: Partial<RateOptions>): Promise<number> {
|
||||
const rates = await Promise.all(this.rates.map((rate) => rate.getRate(currency, options)));
|
||||
return this.policy(rates);
|
||||
/**
|
||||
* Returns a set of all available currency pairs across all adapters
|
||||
*/
|
||||
public async listPairs(): Promise<Set<string>> {
|
||||
// Get available pairs from all adapters
|
||||
const pairPromises = this.adapters.map((adapter) => adapter.listPairs());
|
||||
const pairSets = await Promise.all(pairPromises);
|
||||
|
||||
// Combine all pairs
|
||||
const allPairs = new Set<string>();
|
||||
for (const pairSet of pairSets) {
|
||||
for (const pair of pairSet) {
|
||||
allPairs.add(pair);
|
||||
}
|
||||
}
|
||||
|
||||
return allPairs;
|
||||
}
|
||||
|
||||
public async toBCH(amount: number, fromCurrency: string): Promise<Satoshis> {
|
||||
const rate = await this.getRate(fromCurrency);
|
||||
// Timer for periodic refresh
|
||||
private refreshTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
const bchAmount = amount / rate;
|
||||
return Satoshis.fromBCH(bchAmount);
|
||||
/**
|
||||
* Starts periodic refresh to emit combined rates
|
||||
*/
|
||||
private startPeriodicRefresh(): void {
|
||||
// Clear any existing timer
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
|
||||
// Set up new timer
|
||||
this.refreshTimer = setInterval(() => {
|
||||
this.emitCombinedRates();
|
||||
}, this.refreshIntervalMs);
|
||||
|
||||
// Emit initial rates immediately
|
||||
this.emitCombinedRates();
|
||||
}
|
||||
|
||||
public async fromBCH(amount: Satoshis, toCurrency: string): Promise<number> {
|
||||
const rate = await this.getRate(toCurrency);
|
||||
/**
|
||||
* Handles a rate update from an adapter
|
||||
*/
|
||||
private handleRateUpdate(
|
||||
adapterIndex: number,
|
||||
event: {
|
||||
denominatorUnitCode: string;
|
||||
numeratorUnitCode: string;
|
||||
price: number;
|
||||
},
|
||||
): void {
|
||||
const { denominatorUnitCode, numeratorUnitCode, price } = event;
|
||||
|
||||
const result = amount.toBCH() * rate;
|
||||
return result;
|
||||
// Initialize maps if they don't exist
|
||||
if (!this.rateCache.has(denominatorUnitCode)) {
|
||||
this.rateCache.set(denominatorUnitCode, new Map());
|
||||
}
|
||||
|
||||
const denomMap = this.rateCache.get(denominatorUnitCode)!;
|
||||
if (!denomMap.has(numeratorUnitCode)) {
|
||||
denomMap.set(numeratorUnitCode, new Map());
|
||||
}
|
||||
|
||||
// Store the rate in the cache
|
||||
const rateMap = denomMap.get(numeratorUnitCode)!;
|
||||
rateMap.set(adapterIndex, price);
|
||||
|
||||
// Add pair to available pairs
|
||||
this.availablePairs.add(`${numeratorUnitCode}/${denominatorUnitCode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits combined rates for all available pairs
|
||||
*/
|
||||
private emitCombinedRates(): void {
|
||||
// Process each denominator currency
|
||||
for (const [denominator, numeratorMap] of this.rateCache.entries()) {
|
||||
// Process each numerator currency
|
||||
for (const [numerator, rateMap] of numeratorMap.entries()) {
|
||||
// Get all rates for this pair
|
||||
const rates = Array.from(rateMap.values());
|
||||
|
||||
// Apply policy to get combined rate
|
||||
const combinedRate = this.policy(rates);
|
||||
|
||||
// Only emit if we have a valid rate
|
||||
if (combinedRate !== null) {
|
||||
this.emit('rateUpdated', {
|
||||
denominatorUnitCode: denominator,
|
||||
numeratorUnitCode: numerator,
|
||||
price: combinedRate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type RatesCompositePolicy = (rates: number[]) => number | null;
|
||||
|
||||
/**
|
||||
* Average policy: returns the average of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesAveragePolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0
|
||||
? validRates.reduce((acc, rate) => acc + rate, 0) / validRates.length
|
||||
: null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Median policy: returns the median of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMedianPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates
|
||||
.filter((rate) => !isNaN(rate) && isFinite(rate))
|
||||
.sort((a, b) => a - b);
|
||||
if (validRates.length === 0) return null;
|
||||
|
||||
const mid = Math.floor(validRates.length / 2);
|
||||
return validRates.length % 2 === 0
|
||||
? (validRates[mid - 1] + validRates[mid]) / 2
|
||||
: validRates[mid];
|
||||
};
|
||||
|
||||
/**
|
||||
* Min policy: returns the minimum of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMinPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0 ? Math.min(...validRates) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Max policy: returns the maximum of all valid rates
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesMaxPolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRates = rates.filter((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRates.length > 0 ? Math.max(...validRates) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* First Available policy: returns the first valid rate found
|
||||
* Returns null if no valid rates are available
|
||||
*/
|
||||
export const ratesFirstAvailablePolicy: RatesCompositePolicy = (rates) => {
|
||||
const validRate = rates.find((rate) => !isNaN(rate) && isFinite(rate));
|
||||
return validRate !== undefined ? validRate : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Priority policy factory: returns a policy that uses rates in order of priority
|
||||
* Returns the first valid rate from the adapters in the order they were provided
|
||||
*/
|
||||
export const ratesPriorityPolicyFactory = (): RatesCompositePolicy => {
|
||||
return (rates) => {
|
||||
for (const rate of rates) {
|
||||
if (!isNaN(rate) && isFinite(rate)) {
|
||||
return rate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//TODO: Define a trait that includes methods to retrieve historical prices.
|
||||
// This will only be supported by a few adapters (e.g. Oracle Adapters).
|
||||
// So, when creating these classes, we'll want to do them like so:
|
||||
// class RatesOracleRelay extends BaseRates implements RatesHistorical
|
||||
// Implementations that then require Historical Rates can then specify a type like:
|
||||
// rates: BaseRates & RatesHistorical
|
||||
export type RatesHistorical = { placeholder: 'asd' };
|
||||
|
||||
// TODO: Will probably need this for filtering.
|
||||
// Consider whether a more advanced API is warranted.
|
||||
// E.g. Receiving multiple rates and having a "limit" parameter.
|
||||
// If we find we are over-specializing for Oracles, we don't even necessarily need a trait.
|
||||
// We could just add it as an additional method and then, if someone needs that functionality, they can type it like:
|
||||
// rates: RatesOracleRelay // NOTE: Specifically typed.
|
||||
// Traits are more ideal if we have re-use though.
|
||||
/*
|
||||
export type RateOptions = {
|
||||
// minimum timestamp to use for the rate
|
||||
minTimestamp: number;
|
||||
|
||||
// maximum timestamp to use for the rate
|
||||
maxTimestamp: number;
|
||||
};
|
||||
*/
|
||||
@@ -1,136 +0,0 @@
|
||||
// Tests for CoinbaseRates: verifies public endpoints and conversion logic using stubs
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import {
|
||||
RatesCoinbase,
|
||||
type CoinbaseProduct,
|
||||
} from '~/src/index.js';
|
||||
|
||||
// CoinbaseRates implements BaseRates via Coinbase Exchange REST API
|
||||
describe('CoinbaseRates', () => {
|
||||
it('listProducts should fetch and return product list', async () => {
|
||||
// Arrange: prepare a fake product list and stub fetcher to return it
|
||||
const sampleProducts: CoinbaseProduct[] = [
|
||||
{
|
||||
id: 'BCH-USD',
|
||||
base_currency: 'BCH',
|
||||
quote_currency: 'USD',
|
||||
base_min_size: '0.00000001',
|
||||
base_max_size: '1000000',
|
||||
quote_increment: '0.01',
|
||||
}
|
||||
];
|
||||
const fetcher = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => sampleProducts
|
||||
} as any);
|
||||
|
||||
// Act: instantiate and call listProducts()
|
||||
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
|
||||
const products = await rates.listProducts();
|
||||
|
||||
// Assert: fetcher was invoked on the correct endpoint and data matches
|
||||
expect(fetcher).toHaveBeenCalledWith('https://api.exchange.coinbase.com/products');
|
||||
expect(products).toEqual(sampleProducts);
|
||||
});
|
||||
|
||||
it('getRate should fetch direct pair and cache result', async () => {
|
||||
// Arrange: stub ticker endpoint to return price '2' for direct pair
|
||||
const tickerResponse = { ok: true, json: async () => ({ price: '2' }) } as any;
|
||||
const fetcher = vi.fn().mockResolvedValue(tickerResponse);
|
||||
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
|
||||
|
||||
// Act: first call should invoke fetcher for the direct BCH-USD ticker
|
||||
const rate1 = await rates.getRate('USD');
|
||||
// Assert: parsed value is correct and fetcher was called against the correct URL
|
||||
expect(rate1).toBe(0.5);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
'https://api.exchange.coinbase.com/products/USD-BCH/ticker',
|
||||
);
|
||||
|
||||
// Act: second call should hit the cache (no new network call)
|
||||
const rate2 = await rates.getRate('USD');
|
||||
// Assert: value unchanged and fetcher not called again
|
||||
expect(rate2).toBe(0.5);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('getRate should fallback to inverse when direct fails', async () => {
|
||||
// Arrange: make the direct FETCH fail, then succeed for inverse fetch
|
||||
const error = new Error('not found');
|
||||
const inverseResponse = { ok: true, json: async () => ({ price: '4' }) } as any;
|
||||
const fetcher = vi.fn()
|
||||
.mockRejectedValueOnce(error) // direct USD-BCH will throw
|
||||
.mockResolvedValueOnce(inverseResponse); // then BCH-USD returns price
|
||||
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
|
||||
|
||||
// Act: calling getRate on currencies that require inversion
|
||||
const rate = await rates.getRate('USD');
|
||||
|
||||
// Assert: fetcher first called with USD-BCH then BCH-USD, and final rate is inverted correctly
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.exchange.coinbase.com/products/USD-BCH/ticker'
|
||||
);
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.exchange.coinbase.com/products/BCH-USD/ticker'
|
||||
);
|
||||
expect(rate).toBe(4);
|
||||
});
|
||||
|
||||
it('toBCH and fromBCH should convert amounts using getRate', async () => {
|
||||
// Arrange: create rates instance and stub getRate to return a deterministic factor
|
||||
const tickerResponse = { ok: true, json: async () => ({ price: '2' }) } as any;
|
||||
const fetcher = vi.fn().mockResolvedValue(tickerResponse);
|
||||
const rates = RatesCoinbase.from('https://api.exchange.coinbase.com', 1000, fetcher);
|
||||
// Value returned is 1 usd = 0.5 bch
|
||||
vi.spyOn(rates, 'getRate').mockResolvedValue(2 as any);
|
||||
|
||||
// Act: convert 2 units of foreign currency to BCH
|
||||
const satoshis = await rates.toBCH(2, 'USD');
|
||||
// Assert: 2 / rate (2) => 1 BCH
|
||||
expect(satoshis.toBCH()).toBe(1);
|
||||
|
||||
// Act: convert 2 BCH back to foreign currency
|
||||
const foreignAmount = await rates.fromBCH(Satoshis.fromBCH(2), 'USD');
|
||||
// Assert: 2 BCH * rate (2) => 4 units of foreign currency
|
||||
expect(foreignAmount).toBe(4);
|
||||
});
|
||||
|
||||
// --- Integration tests against actual Coinbase API (no mocking) ---
|
||||
it('integration: listProducts should return a non-empty array', async () => {
|
||||
// Use default fetcher to call real /products endpoint
|
||||
const rates = RatesCoinbase.from();
|
||||
const products = await rates.listProducts();
|
||||
|
||||
// Expect an array of products and at least one result
|
||||
expect(Array.isArray(products)).toBe(true);
|
||||
expect(products.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('integration: getRate BCH->USD should yield a positive number', async () => {
|
||||
// Call real ticker endpoint
|
||||
const rates = RatesCoinbase.from();
|
||||
const rate = await rates.getRate('USD');
|
||||
|
||||
// Expect a numeric rate greater than zero
|
||||
expect(typeof rate).toBe('number');
|
||||
expect(rate).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('integration: toBCH/fromBCH roundtrip with USD should work', async () => {
|
||||
// Convert 1 USD to BCH (satoshis) then back to USD
|
||||
const rates = RatesCoinbase.from();
|
||||
const sats = await rates.toBCH(1, 'USD');
|
||||
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
|
||||
|
||||
const back = await rates.fromBCH(sats, 'USD');
|
||||
|
||||
expect(back).toBeGreaterThan(0);
|
||||
expect(back).toBeCloseTo(1.00, 3);
|
||||
|
||||
|
||||
}, { timeout: 20000 });
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Satoshis } from '@xocash/primitives';
|
||||
|
||||
import {
|
||||
RatesComposite,
|
||||
BaseRates,
|
||||
RatesCoinbase,
|
||||
} from '~/src/index.js';
|
||||
|
||||
/**
|
||||
* A simple stub that implements BaseRates and returns fixed values.
|
||||
*/
|
||||
class StubRate extends BaseRates {
|
||||
constructor(private factor: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {}
|
||||
public async stop(): Promise<void> {}
|
||||
|
||||
/** Return a fixed rate factor */
|
||||
public async getRate(): Promise<number> {
|
||||
return this.factor;
|
||||
}
|
||||
|
||||
/** toBCH returns Satoshis.fromBCH(amount * factor) */
|
||||
public async toBCH(amount: number): Promise<Satoshis> {
|
||||
return Satoshis.fromBCH(amount / this.factor);
|
||||
}
|
||||
|
||||
/** fromBCH returns (BCH amount) * factor */
|
||||
public async fromBCH(amount: Satoshis): Promise<number> {
|
||||
return amount.toBCH() * this.factor;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests with in-memory stub rates ---
|
||||
describe('RatesComposite (unit) with two stub rates', () => {
|
||||
// Use 99 and 101 so average will be exactly 100
|
||||
const stub1 = new StubRate(99);
|
||||
const stub2 = new StubRate(101);
|
||||
const composite = new RatesComposite([stub1, stub2]);
|
||||
|
||||
it('getRate should average the two rates to 100', async () => {
|
||||
// Act: composite over stub rates
|
||||
const avg = await composite.getRate('ANY');
|
||||
// Assert: (99 + 101) / 2 = 100
|
||||
expect(avg).toBeCloseTo(100, 3);
|
||||
});
|
||||
|
||||
it('toBCH should average returned BCH to 100 BCH', async () => {
|
||||
// amount=1, stub1 gives 99 BCH, stub2 gives 101 BCH -> avg = 100 BCH
|
||||
const sats = await composite.toBCH(1, 'ANY');
|
||||
|
||||
// Expect 1 USD = 1/100 BCH
|
||||
expect(sats.toBCH()).toBeCloseTo(0.01, 3);
|
||||
});
|
||||
|
||||
it('fromBCH should average returned foreign value to 100', async () => {
|
||||
// 1 BCH => stub1 returns 99, stub2 returns 101 -> avg = 100
|
||||
const value = await composite.fromBCH(Satoshis.fromBCH(1), 'ANY');
|
||||
expect(value).toBeCloseTo(100, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RatesComposite (unit) with two very different stub rates', () => {
|
||||
const stub1 = new StubRate(100);
|
||||
const stub2 = new StubRate(600);
|
||||
const composite = new RatesComposite([stub1, stub2]);
|
||||
|
||||
it('toBCH should average returned BCH to 100 BCH', async () => {
|
||||
// amount=1, stub1 gives 100 per BCH, stub2 gives 600 per BCH -> avg = 350 per BCH
|
||||
const sats = await composite.toBCH(1, 'ANY');
|
||||
|
||||
// Expect 1 USD = 1/350 BCH
|
||||
expect(sats.toBCH()).toBeCloseTo(0.00285, 4);
|
||||
});
|
||||
|
||||
it('fromBCH should average returned foreign value to 100', async () => {
|
||||
// 1 BCH => stub1 returns 100, stub2 returns 600 -> avg = 350
|
||||
const value = await composite.fromBCH(Satoshis.fromBCH(1), 'ANY');
|
||||
expect(value).toBeCloseTo(350, 3);
|
||||
});
|
||||
|
||||
it('composite toBCH/fromBCH roundtrip USD', async () => {
|
||||
const sats = await composite.toBCH(1, 'USD');
|
||||
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
|
||||
|
||||
const back = await composite.fromBCH(sats, 'USD');
|
||||
expect(back).toBeGreaterThan(0);
|
||||
expect(back).toBeCloseTo(1.00, 3);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
// --- Integration tests combining CoinbaseRates and GpOraclesRates ---
|
||||
describe('AvgCompositeRates (integration) combining Coinbase & OracleRates', () => {
|
||||
it('integration: composite BCH->USD from Coinbase+Oracle should yield > 0', async () => {
|
||||
const cb = RatesCoinbase.from();
|
||||
const mockRate = new StubRate(99);
|
||||
|
||||
const composite = new RatesComposite([cb, mockRate]);
|
||||
await composite.start();
|
||||
|
||||
const rate = await composite.getRate('USD');
|
||||
|
||||
await composite.stop();
|
||||
expect(rate).toBeGreaterThan(0);
|
||||
}, 20000);
|
||||
|
||||
it('integration: composite toBCH/fromBCH roundtrip USD', async () => {
|
||||
const cb = RatesCoinbase.from();
|
||||
const mockRate = new StubRate(100);
|
||||
|
||||
const composite = new RatesComposite([cb, mockRate]);
|
||||
await composite.start();
|
||||
|
||||
const sats = await composite.toBCH(1, 'USD');
|
||||
expect(sats.toBigInt()).toBeGreaterThan(BigInt(0));
|
||||
|
||||
const back = await composite.fromBCH(sats, 'USD');
|
||||
expect(back).toBeGreaterThan(0);
|
||||
expect(back).toBeCloseTo(1.00, 3);
|
||||
|
||||
await composite.stop();
|
||||
}, 20000);
|
||||
});
|
||||
Reference in New Issue
Block a user