import { EventEmitter } from '../utils/event-emitter.js'; import { type RatesEventMap, } from '../utils/rates/base-rates.js'; import { RatesOracle } from '../utils/rates/rates-oracles.js'; /** * Event map emitted by {@link RatesService}. */ export type RatesServiceEventMap = { 'rate-updated': { numeratorUnitCode: string; denominatorUnitCode: string; price: number; pair: string; updatedAt: number; }; }; /** * In-memory representation of a market rate. */ type CachedRate = { price: number; updatedAt: number; }; /** * Minimal adapter contract that RatesService depends on. * * Using a small interface keeps the service decoupled and avoids inheriting * implementation-specific type constraints from concrete adapters. */ export interface RatesAdapter { start(): Promise; stop(): Promise; listPairs(): Promise>; formatCurrency(amount: number, targetCurrency: string): string; on( type: 'rateUpdated', listener: (detail: RatesEventMap['rateUpdated']) => void, ): () => void; } /** * Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers * for the TUI. * * This service keeps a small in-memory snapshot of the latest prices and emits * a normalized event whenever a pair changes. React components can subscribe * through `useSyncExternalStore` for clean and predictable reactivity. */ export class RatesService extends EventEmitter { private readonly adapter: RatesAdapter; private readonly ratesByPair = new Map(); private unsubscribeFromAdapter: (() => void) | null = null; private started = false; constructor(adapter: RatesAdapter) { super(); this.adapter = adapter; } /** * Creates a rates service. * * If no adapter is passed, this defaults to the Oracle-backed adapter. */ public static async create(adapter?: RatesAdapter): Promise { const resolvedAdapter = adapter ?? (await RatesOracle.from()); return new RatesService(resolvedAdapter); } /** * Starts the underlying adapter and begins collecting live updates. */ public async start(): Promise { if (this.started) { return; } this.started = true; this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => { this.handleRateUpdated(event); }); try { await this.adapter.start(); } catch (error) { this.unsubscribeFromAdapter?.(); this.unsubscribeFromAdapter = null; this.started = false; throw error; } } /** * Stops live rate collection. */ public async stop(): Promise { if (!this.started) { return; } this.started = false; this.unsubscribeFromAdapter?.(); this.unsubscribeFromAdapter = null; await this.adapter.stop(); } /** * Returns the latest price for a pair in NUMERATOR/DENOMINATOR form. * * Example: `getRate("USD", "BCH")`. */ public getRate( numeratorUnitCode: string, denominatorUnitCode: string, ): number | null { const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); return this.ratesByPair.get(pair)?.price ?? null; } /** * Converts satoshis to fiat using the latest BCH/fiat rate. * * Example: `convertBchToFiat(1234n, "USD")`. */ public convertBchToFiat( satoshis: bigint, targetCurrency: string = 'USD', ): number | null { const rate = this.getRate(targetCurrency, 'BCH'); if (rate === null) { return null; } const amountInBch = Number(satoshis) / 100_000_000; return amountInBch * rate; } /** * Formats a BCH -> fiat converted amount using the adapter formatter. */ public formatBchToFiat( satoshis: bigint, targetCurrency: string = 'USD', ): string | null { const normalizedCurrency = targetCurrency.toUpperCase(); const amount = this.convertBchToFiat(satoshis, normalizedCurrency); if (amount === null) { return null; } return this.adapter.formatCurrency(amount, normalizedCurrency); } /** * Formats an arbitrary fiat amount in a currency-aware way. */ public formatCurrency(amount: number, currencyCode: string): string { return this.adapter.formatCurrency(amount, currencyCode.toUpperCase()); } /** * Handles normalized updates from the underlying adapter. */ private handleRateUpdated(event: RatesEventMap['rateUpdated']): void { const numeratorUnitCode = event.numeratorUnitCode.toUpperCase(); const denominatorUnitCode = event.denominatorUnitCode.toUpperCase(); const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); const updatedAt = Date.now(); this.ratesByPair.set(pair, { price: event.price, updatedAt, }); this.emit('rate-updated', { numeratorUnitCode, denominatorUnitCode, price: event.price, pair, updatedAt, }); } /** * Creates a stable key for pair lookups. */ private getPairKey( numeratorUnitCode: string, denominatorUnitCode: string, ): string { return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`; } }