From e15cfb1ce8af920b753f898ab4dcba162ccc901e Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sun, 15 Jun 2025 23:53:57 +0000 Subject: [PATCH] Add Oracle Rates Adapter --- package-lock.json | 17 ++- packages/stack/package.json | 1 + packages/stack/src/index.ts | 1 + packages/stack/src/rates/rates-oracle.ts | 158 +++++++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 packages/stack/src/rates/rates-oracle.ts diff --git a/package-lock.json b/package-lock.json index ffc46a4..9cc3317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1156,6 +1156,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@generalprotocols/oracle-client": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1.tgz", + "integrity": "sha512-TmnPCUm1VYeWK7SkWV8w3jAjUOW9SFLPgF8ni06ouaGYyer/35oZ5OW+6R3kpFtRWhO4rlnT9HYL8SHE3Yj0+A==", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.0.0", + "zod": "^3.24.3" + } + }, "node_modules/@gerrit0/mini-shiki": { "version": "1.24.4", "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-1.24.4.tgz", @@ -14695,9 +14705,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.64", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.64.tgz", + "integrity": "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -14748,6 +14758,7 @@ "dependencies": { "@electrum-cash/network": "^4.1.1", "@electrum-cash/protocol": "2.1.0-development.8075178089", + "@generalprotocols/oracle-client": "^0.0.1", "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", "idb": "8.0.3", diff --git a/packages/stack/package.json b/packages/stack/package.json index 33bac88..6474f92 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -36,6 +36,7 @@ "dependencies": { "@electrum-cash/network": "^4.1.1", "@electrum-cash/protocol": "2.1.0-development.8075178089", + "@generalprotocols/oracle-client": "^0.0.1", "@xocash/primitives": "*", "better-sqlite3": "^11.8.1", "idb": "8.0.3", diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index c15213b..9344975 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -16,6 +16,7 @@ export * from './storage/store-remote-http.js'; export * from './rates/rates-bitpay.js'; export * from './rates/rates-coinbase.js'; export * from './rates/rates-cryptocompare.js'; +export * from './rates/rates-oracle.js'; export * from './wallet/wallet-hd.js'; export * from './wallet/wallet-p2pkh.js'; export * from './wallet/wallet-p2sh20.js'; diff --git a/packages/stack/src/rates/rates-oracle.ts b/packages/stack/src/rates/rates-oracle.ts new file mode 100644 index 0000000..24b06ec --- /dev/null +++ b/packages/stack/src/rates/rates-oracle.ts @@ -0,0 +1,158 @@ +import { + OracleClient, + OracleMetadataMap, + OracleMetadataMessage, + OraclePriceMessage, +} from '@generalprotocols/oracle-client'; + +import { BaseRates } from './index.js'; + +// 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) { + const ratesOracle = new RatesOracle(client ?? (await OracleClient.from())); + + return ratesOracle; + } + + private client: OracleClient; + private oracles: OracleMetadataMap; + + private started: boolean = false; + + private constructor(client: OracleClient) { + super(); + + this.client = client; + this.oracles = {}; + } + + /** + * Start the rates oracle and the underlying client. + */ + async start() { + if (this.started) { + return; + } + this.started = true; + + // 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; + + // 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() { + 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.all( + oracles.map(async (oracle) => { + 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 + ) { + return msg; + } + return latest; + }, messages[0]); + + // If the message is a price message, handle it. + if (message instanceof OraclePriceMessage) { + this.handlePriceMessage(message); + } + }), + ); + } + + /** + * 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; + } + + // Scale the price + const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; + + this.emit('rateUpdated', { + numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE, + denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE, + price: priceValue, + }); + } +}