import { OracleClient, OracleMetadataMessage, OraclePriceMessage, type OracleMetadataMap, } from "@generalprotocols/oracle-client"; import { type RatesEventMap, BaseRates } from "./base-rates.js"; import { type OffCallback } from "../event-emitter.js"; import { SettingsService } from "../../services/settings.js"; // Add the Oracle Price Message to our Events for this Adapter. export type RatesOracleEventMap = RatesEventMap & { rateUpdated: { oraclePriceMessage: OraclePriceMessage; }; }; // TODO: Add RatesHistorical trait since Oracles can provide historical rates. export class RatesOracle extends BaseRates { /** * Create a new rates oracle. * * @param client The underlying oracle client. If not provided, a new client will be created. * @returns The rates oracle. */ static async from( client?: OracleClient, settings: SettingsService = new SettingsService(), ) { const ratesOracle = new RatesOracle( client ?? (await OracleClient.from()), settings, ); return ratesOracle; } private client: OracleClient; private settings: SettingsService; private oracles: OracleMetadataMap; private started: boolean = false; private targetNumeratorUnitCode: string; private targetDenominatorUnitCode: string = "BCH"; private unsubscribeFromSettings: OffCallback | null = null; public constructor(client: OracleClient, settings: SettingsService) { super(); this.client = client; this.settings = settings; this.oracles = {}; this.targetNumeratorUnitCode = settings.getCurrency().toUpperCase(); } /** * Start the rates oracle and the underlying client. */ async start() { if (this.started) { return; } this.started = true; this.unsubscribeFromSettings = this.settings.on( "settings-updated", this.handleSettingsUpdated.bind(this), ); // Create event listeners for the client. this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this)); this.client.setOnPriceMessage(this.handlePriceMessage.bind(this)); // Get the metadata for the client. this.oracles = await this.client.getMetadataMap(); // Start the client. await this.client.start(); // Refresh the prices. await this.refreshPrices(); } /** * Stop the rates oracle and the underlying client. */ async stop() { if (!this.started) { return; } this.started = false; this.unsubscribeFromSettings?.(); this.unsubscribeFromSettings = null; // Remove event listeners by setting them to empty functions. this.client.setOnMetadataMessage(() => {}); this.client.setOnPriceMessage(() => {}); await this.client.stop(); } /** * List the pairs that we are tracking. * * @returns A set of pairs. */ async listPairs() { // If metadata has not arrived yet but the client is running, query once so // callers (like the currency picker) can still discover available pairs. if (Object.keys(this.oracles).length === 0 && this.started) { this.oracles = await this.client.getMetadataMap(); } return new Set( Object.values(this.oracles).map((oracle) => { return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`; }), ); } /** * Get the latest prices for all the pairs and emit a rate updated event for each. */ public async refreshPrices() { const oracles = await this.client.getOracles(); // For each oracle, get the lastest dataSequence (price) message and emit a rate updated event. await Promise.allSettled( oracles.map(async (oracle) => { try { const messages = await this.client.getOracleMessages({ publicKey: oracle.publicKey, minDataSequence: 1, count: 1, }); // We are only expecting a single message back. Just in case, we take the latest one. const message = messages.reduce((latest, msg) => { if ( msg instanceof OraclePriceMessage && msg.messageSequence > (latest?.messageSequence ?? 0) ) { return msg; } return latest; }, messages[0]); // If the message is a price message, handle it. if (message instanceof OraclePriceMessage) { this.handlePriceMessage(message); } } catch (error) { console.error( "Error refreshing prices for oracle:", oracle.publicKey, error, ); } }), ); } /** * Update the metadata map that we use to track the pairs. * * @param message The metadata message. */ private handleMetadataMessage(message: OracleMetadataMessage) { this.oracles = OracleClient.updateMetadataMap(this.oracles, message); } /** * Emit a rate updated event for the given pair. * * @param message The price message. */ private handlePriceMessage(message: OraclePriceMessage) { const oracle = this.oracles[message.toHexObject().publicKey]; // If the oracle doesn't have the required metadata, we can't use it. if ( !oracle || !oracle.SOURCE_NUMERATOR_UNIT_CODE || !oracle.SOURCE_DENOMINATOR_UNIT_CODE || !oracle.ATTESTATION_SCALING ) { return; } const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase(); const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase(); // Only emit the pair currently selected in settings. if ( sourceNumeratorUnitCode !== this.targetNumeratorUnitCode || sourceDenominatorUnitCode !== this.targetDenominatorUnitCode ) { return; } // Scale the price const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; this.emit("rateUpdated", { numeratorUnitCode: sourceNumeratorUnitCode, denominatorUnitCode: sourceDenominatorUnitCode, price: priceValue, oraclePriceMessage: message, }); } /** * Tracks updates to settings and switches the actively emitted fiat pair. */ private handleSettingsUpdated(event: { key: "currency" | "default-mnemonic"; value: string | undefined; }) { if (event.key !== "currency" || !event.value) { return; } this.targetNumeratorUnitCode = event.value.toUpperCase(); // Refresh so listeners get the latest value for the new currency quickly. if (this.started) { this.refreshPrices().catch((error) => { console.error("Error refreshing prices after currency update:", error); }); } } }