diff --git a/packages/stack/src/rates/base-rates.ts b/packages/stack/src/rates/base-rates.ts index ff1e6cb..49d23d5 100644 --- a/packages/stack/src/rates/base-rates.ts +++ b/packages/stack/src/rates/base-rates.ts @@ -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 { + /** Starts the given rates adapter so that it will emit events on price updates. */ public abstract start(): Promise; + /** Stops the given rates adapter so that it will stop checking for price updates. */ public abstract stop(): Promise; /** - * 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): Promise; - - /** - * 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): Promise; - - /** - * 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): Promise; + public abstract listPairs(): Promise>; + // 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 { diff --git a/packages/stack/src/rates/index.ts b/packages/stack/src/rates/index.ts index 5063d84..ff49720 100644 --- a/packages/stack/src/rates/index.ts +++ b/packages/stack/src/rates/index.ts @@ -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'; diff --git a/packages/stack/src/rates/rates-bitpay.ts b/packages/stack/src/rates/rates-bitpay.ts new file mode 100644 index 0000000..10c6506 --- /dev/null +++ b/packages/stack/src/rates/rates-bitpay.ts @@ -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; + private readonly opts: RatesBitPayOptions; + + // 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(); + + private constructor( + currencyCodes: Array, + opts: Partial, + ) { + 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 = ['BCH'], + opts: Partial = {}, + ): RatesBitPay { + return new RatesBitPay(currencyCodes, opts); + } + + public async start(): Promise { + // 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 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()); + } + + // Current fiat currencies for this crypto + const currentFiats = new Set(); + + // 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; +}; + +// 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(), + }), +); diff --git a/packages/stack/src/rates/rates-coinbase.ts b/packages/stack/src/rates/rates-coinbase.ts index 665407e..801ae00 100644 --- a/packages/stack/src/rates/rates-coinbase.ts +++ b/packages/stack/src/rates/rates-coinbase.ts @@ -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; -}; +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; + private readonly currencyCodes: Array; + private readonly opts: RatesCoinbaseOptions; - private constructor(deps: Partial) { + // 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(); + + private constructor( + currencyCodes: Array, + opts: Partial, + ) { 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, + currencyCodes: Array = ['BCH'], + opts: Partial = {}, ): 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 {} + public async start(): Promise { + // Initial refresh to populate available pairs + await this.refreshPrices(); - /** No persistent connection to close. */ - public async stop(): Promise {} + // 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 { - 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 { + 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(); } - return (await res.json()) as CoinbaseProduct[]; - } - /** - * Get the raw exchange rate for a currency pair. - */ - public async getRate( - currency: string, - options?: Partial, - ): Promise { - const fetchPair = async (pair: string): Promise => { - 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(); - 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, - ): Promise { - 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()); + } - /** - * Convert a BCH amount to a target currency. - */ - public async fromBCH( - amount: Satoshis, - targetCurrency: string, - options?: Partial, - ): Promise { - 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(); - /** - * Fetch the current ticker price for a given pair. - */ - private async fetchTickerPrice(pair: string): Promise { - 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; +}; + +// 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()), + }), +}); diff --git a/packages/stack/src/rates/rates-composite.ts b/packages/stack/src/rates/rates-composite.ts index 06b0450..383e8de 100644 --- a/packages/stack/src/rates/rates-composite.ts +++ b/packages/stack/src/rates/rates-composite.ts @@ -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 = new Set(); + + // Cache of the latest rates from each adapter + private rateCache: Map>> = 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 { - 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 { - 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): Promise { - 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> { + // 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(); + for (const pairSet of pairSets) { + for (const pair of pairSet) { + allPairs.add(pair); + } + } + + return allPairs; } - public async toBCH(amount: number, fromCurrency: string): Promise { - const rate = await this.getRate(fromCurrency); + // Timer for periodic refresh + private refreshTimer?: ReturnType; - 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 { - 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; + }; +}; diff --git a/packages/stack/src/rates/traits.ts b/packages/stack/src/rates/traits.ts new file mode 100644 index 0000000..ac17d3d --- /dev/null +++ b/packages/stack/src/rates/traits.ts @@ -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; +}; +*/ diff --git a/packages/stack/tests/rates/rates-coinbase.spec.ts b/packages/stack/tests/rates/rates-coinbase.spec.ts deleted file mode 100644 index bae814c..0000000 --- a/packages/stack/tests/rates/rates-coinbase.spec.ts +++ /dev/null @@ -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 }); -}); \ No newline at end of file diff --git a/packages/stack/tests/rates/rates-composite.spec.ts b/packages/stack/tests/rates/rates-composite.spec.ts deleted file mode 100644 index 02aa38e..0000000 --- a/packages/stack/tests/rates/rates-composite.spec.ts +++ /dev/null @@ -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 {} - public async stop(): Promise {} - - /** Return a fixed rate factor */ - public async getRate(): Promise { - return this.factor; - } - - /** toBCH returns Satoshis.fromBCH(amount * factor) */ - public async toBCH(amount: number): Promise { - return Satoshis.fromBCH(amount / this.factor); - } - - /** fromBCH returns (BCH amount) * factor */ - public async fromBCH(amount: Satoshis): Promise { - 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); -});