Added Rates Service: Base, Coinbase, Composite

This commit is contained in:
2025-04-28 12:36:30 +00:00
committed by Jim Hamill
parent c168d6124e
commit a0395514ba
8 changed files with 590 additions and 6 deletions
+1
View File
@@ -3,6 +3,7 @@ export * from '@xocash/primitives';
// Export all of our stack-specific modules.
export * from './blockchain/index.js';
export * from './rates/index.js';
export * from './storage/index.js';
export * from './utils/index.js';
export * from './wallet/index.js';
+78
View File
@@ -0,0 +1,78 @@
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;
}
export type RatesEventMap = {
'price-updated': {
currency: string;
price: number;
}
}
export abstract class BaseRates extends EventEmitter<RatesEventMap> {
public abstract start(): Promise<void>;
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.
*/
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>;
/**
* 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 {
const minimumFractionDigitsMap: { [currency: string]: number } = {
AUD: 2,
BCH: 8,
USD: 2,
};
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: targetCurrency,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
});
return formatter.format(amount);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './base-rates.js';
export * from './rates-composite.js';
export * from './rates-coinbase.js';
+156
View File
@@ -0,0 +1,156 @@
import { Satoshis } from '@xocash/primitives';
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>;
};
/**
* CoinbaseRates
*
* 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 constructor(deps: Partial<RatesCoinbaseDependencies>) {
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);
}
/**
* 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>,
): RatesCoinbase {
const cache = new Memoizer();
return new RatesCoinbase({ apiBaseUrl, cache, cacheTTL, fetch });
}
/** No persistent connection to open. */
public async start(): Promise<void> {}
/** No persistent connection to close. */
public async stop(): Promise<void> {}
/**
* 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}`);
}
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 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 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);
}
/**
* 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;
}
/**
* 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}`);
}
const data = (await res.json()) as { price: string };
return parseFloat(data.price);
}
}
@@ -0,0 +1,43 @@
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;
/**
* A composite rate that averages the rates of multiple rates.
*/
export class RatesComposite extends BaseRates {
constructor(private rates: BaseRates[], private policy: RatesCompositePolicy = ratesAveragePolicy) {
super();
}
public async start(): Promise<void> {
await Promise.all(this.rates.map((rate) => rate.start()));
}
public async stop(): Promise<void> {
await Promise.all(this.rates.map((rate) => rate.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);
}
public async toBCH(amount: number, fromCurrency: string): Promise<Satoshis> {
const rate = await this.getRate(fromCurrency);
const bchAmount = amount / rate;
return Satoshis.fromBCH(bchAmount);
}
public async fromBCH(amount: Satoshis, toCurrency: string): Promise<number> {
const rate = await this.getRate(toCurrency);
const result = amount.toBCH() * rate;
return result;
}
}
+45 -6
View File
@@ -1,7 +1,25 @@
// TODO: We may want to move this to @xocash/primitives.
export type MemoizerItem<T> = {
value: T;
timestamp: number;
}
export class Memoizer {
private cache = new Map<string, any>();
private cache = new Map<string, MemoizerItem<any>>();
/**
* Sets a cached value for the given key.
*
* @param key - The key to store the value under
* @param value - The value to store
*/
set(key: string, value: any): void {
this.cache.set(key, {
value,
timestamp: Date.now(),
});
}
/**
* Gets a cached value for the given key, computing it if it doesn't exist.
@@ -10,11 +28,16 @@ export class Memoizer {
* @param compute - Function to compute the value if it's not cached
* @returns The cached or computed value
*/
get<K extends string, V>(key: K, compute: () => V): V {
if (!this.cache.has(key)) {
this.cache.set(key, compute());
get<K extends string, V>(key: K, compute: () => V, ttl?: number): V {
if (ttl) {
this.removeIfExpired(key, ttl);
}
return this.cache.get(key) as V;
if (!this.cache.has(key)) {
this.set(key, compute());
}
return this.cache.get(key)!.value as V;
}
/**
@@ -23,7 +46,11 @@ export class Memoizer {
* @param key - The key to check
* @returns Whether the key exists in the cache
*/
has(key: string): boolean {
has(key: string, ttl?: number): boolean {
if (ttl) {
this.removeIfExpired(key, ttl);
}
return this.cache.has(key);
}
@@ -52,4 +79,16 @@ export class Memoizer {
keys(): string[] {
return Array.from(this.cache.keys());
}
private removeIfExpired(key: string, ttl: number): void {
if (!this.cache.has(key)) {
return;
}
const item = this.cache.get(key);
if (Date.now() - item!.timestamp > ttl) {
this.cache.delete(key);
}
}
}
@@ -0,0 +1,136 @@
// 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 });
});
@@ -0,0 +1,128 @@
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);
});