From a0395514ba93dfa2c12b4eb362cfc4f4695491c7 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 28 Apr 2025 12:36:30 +0000 Subject: [PATCH] Added Rates Service: Base, Coinbase, Composite --- packages/stack/src/index.ts | 1 + packages/stack/src/rates/base-rates.ts | 78 +++++++++ packages/stack/src/rates/index.ts | 3 + packages/stack/src/rates/rates-coinbase.ts | 156 ++++++++++++++++++ packages/stack/src/rates/rates-composite.ts | 43 +++++ packages/stack/src/utils/memoizer.ts | 51 +++++- .../stack/tests/rates/rates-coinbase.spec.ts | 136 +++++++++++++++ .../stack/tests/rates/rates-composite.spec.ts | 128 ++++++++++++++ 8 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 packages/stack/src/rates/base-rates.ts create mode 100644 packages/stack/src/rates/index.ts create mode 100644 packages/stack/src/rates/rates-coinbase.ts create mode 100644 packages/stack/src/rates/rates-composite.ts create mode 100644 packages/stack/tests/rates/rates-coinbase.spec.ts create mode 100644 packages/stack/tests/rates/rates-composite.spec.ts diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 09bfb97..2a45625 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -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'; diff --git a/packages/stack/src/rates/base-rates.ts b/packages/stack/src/rates/base-rates.ts new file mode 100644 index 0000000..ff1e6cb --- /dev/null +++ b/packages/stack/src/rates/base-rates.ts @@ -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 { + public abstract start(): Promise; + + 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. + */ + 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; + + /** + * 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); + } +} diff --git a/packages/stack/src/rates/index.ts b/packages/stack/src/rates/index.ts new file mode 100644 index 0000000..5063d84 --- /dev/null +++ b/packages/stack/src/rates/index.ts @@ -0,0 +1,3 @@ +export * from './base-rates.js'; +export * from './rates-composite.js'; +export * from './rates-coinbase.js'; diff --git a/packages/stack/src/rates/rates-coinbase.ts b/packages/stack/src/rates/rates-coinbase.ts new file mode 100644 index 0000000..665407e --- /dev/null +++ b/packages/stack/src/rates/rates-coinbase.ts @@ -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; +}; + +/** + * 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; + + private constructor(deps: 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); + } + + /** + * 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, + ): RatesCoinbase { + const cache = new Memoizer(); + return new RatesCoinbase({ apiBaseUrl, cache, cacheTTL, fetch }); + } + + /** No persistent connection to open. */ + public async start(): Promise {} + + /** No persistent connection to close. */ + public async stop(): Promise {} + + /** + * 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}`); + } + 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 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, + ): Promise { + 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, + ): Promise { + 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 { + 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); + } +} diff --git a/packages/stack/src/rates/rates-composite.ts b/packages/stack/src/rates/rates-composite.ts new file mode 100644 index 0000000..06b0450 --- /dev/null +++ b/packages/stack/src/rates/rates-composite.ts @@ -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 { + await Promise.all(this.rates.map((rate) => rate.start())); + } + + public async stop(): Promise { + await Promise.all(this.rates.map((rate) => rate.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); + } + + public async toBCH(amount: number, fromCurrency: string): Promise { + const rate = await this.getRate(fromCurrency); + + const bchAmount = amount / rate; + return Satoshis.fromBCH(bchAmount); + } + + public async fromBCH(amount: Satoshis, toCurrency: string): Promise { + const rate = await this.getRate(toCurrency); + + const result = amount.toBCH() * rate; + return result; + } +} diff --git a/packages/stack/src/utils/memoizer.ts b/packages/stack/src/utils/memoizer.ts index 1e8776a..c6c8e19 100644 --- a/packages/stack/src/utils/memoizer.ts +++ b/packages/stack/src/utils/memoizer.ts @@ -1,7 +1,25 @@ // TODO: We may want to move this to @xocash/primitives. +export type MemoizerItem = { + value: T; + timestamp: number; +} + export class Memoizer { - private cache = new Map(); + private cache = new Map>(); + + /** + * 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(key: K, compute: () => V): V { - if (!this.cache.has(key)) { - this.cache.set(key, compute()); + get(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); + } + } } diff --git a/packages/stack/tests/rates/rates-coinbase.spec.ts b/packages/stack/tests/rates/rates-coinbase.spec.ts new file mode 100644 index 0000000..bae814c --- /dev/null +++ b/packages/stack/tests/rates/rates-coinbase.spec.ts @@ -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 }); +}); \ 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 new file mode 100644 index 0000000..02aa38e --- /dev/null +++ b/packages/stack/tests/rates/rates-composite.spec.ts @@ -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 {} + 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); +});