198 lines
5.1 KiB
TypeScript
198 lines
5.1 KiB
TypeScript
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<void>;
|
|
stop(): Promise<void>;
|
|
listPairs(): Promise<Set<string>>;
|
|
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<RatesServiceEventMap> {
|
|
private readonly adapter: RatesAdapter;
|
|
private readonly ratesByPair = new Map<string, CachedRate>();
|
|
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<RatesService> {
|
|
const resolvedAdapter = adapter ?? (await RatesOracle.from());
|
|
return new RatesService(resolvedAdapter);
|
|
}
|
|
|
|
/**
|
|
* Starts the underlying adapter and begins collecting live updates.
|
|
*/
|
|
public async start(): Promise<void> {
|
|
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<void> {
|
|
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()}`;
|
|
}
|
|
}
|