UNPKG

@acurast/oracle-service-sdk

Version:

The Acurast Oracle SDK is a TypeScript library that provides a simple interface to interact with the Acurast Oracle Service. It allows developers to fetch price data for various cryptocurrency pairs, with signatures from multiple oracles.

425 lines 18.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AcurastOracleSDK = void 0; const dapp_1 = require("@acurast/dapp"); const elliptic_1 = require("elliptic"); const buffer_1 = require("buffer"); const uuid_1 = require("uuid"); /** * AcurastOracleSDK provides methods to interact with the Acurast Oracle network. */ class AcurastOracleSDK { client; keyPair; pendingRequests = new Map(); initPromise; oracles = []; wssUrls = []; timeout; logging; errorThreshold; idToPubKeyMap = {}; /** * Creates an instance of AcurastOracleSDK. * @param {AcurastOracleSDKOptions} options - The configuration options. */ constructor(options) { this.keyPair = this.generateKeyPair(); this.timeout = options.timeout || 10 * 1000; // Default 10 seconds timeout this.logging = options.logging || false; this.errorThreshold = options.errorThreshold || 0.333; this.initPromise = this.init(options); } async init(options) { let defaultSettings = { wssUrls: [], oracles: [], }; if (!options.wssUrls || !options.oracles || options.wssUrls.length === 0 || options.oracles.length === 0) { this.log('🔍 Fetching default settings ...'); defaultSettings = await this.fetchDefaultSettings(); this.log(`Fetched default settings: ${JSON.stringify(defaultSettings)}`); } if (options.oracles && options.oracles.length > 0) { this.log(`Using provided oracles : ${options.oracles}`); this.oracles = options.oracles; } else if (defaultSettings.oracles.length > 0) { this.log('Using default oracles ...'); this.oracles = defaultSettings.oracles; } else { throw new Error('No oracles provided'); } if (options.wssUrls && options.wssUrls.length > 0) { this.log(`Using provided wssUrls : ${options.wssUrls}`); this.wssUrls = options.wssUrls; } else if (defaultSettings.wssUrls.length > 0) { this.log('Using default wssUrls ...'); this.wssUrls = defaultSettings.wssUrls; } else { throw new Error('No wssUrls provided'); } this.log('🛜 Opening websocket connection ...'); try { this.client = new dapp_1.AcurastClient(this.wssUrls); await this.client.start({ secretKey: this.keyPair.privateKey, publicKey: this.keyPair.publicKey, }); this.log('✅ Connection opened'); // map oracle public keys to their ids this.idToPubKeyMap = {}; this.oracles.map((oracle) => { const id = this.client.idFromPublicKey(oracle); this.idToPubKeyMap[id] = oracle; }); this.client.onMessage(this.handleMessage.bind(this)); } catch (error) { this.log(`❌ Failed to open connection:, ${error}`); throw error; } } async fetchDefaultSettings() { try { const response = await fetch('https://acurast-oracle-service.storage.googleapis.com/sdk_settings.json'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const settings = await response.json(); return settings; } catch (error) { this.log(`Failed to fetch default settings: ${error}`, 'error'); return { wssUrls: [], oracles: [] }; } } generateKeyPair() { const EC = new elliptic_1.ec('p256'); const keyPair = EC.genKeyPair(); return { privateKey: keyPair.getPrivate('hex'), publicKey: keyPair.getPublic(true, 'hex'), }; } handleMessage(message) { try { const payload = JSON.parse(buffer_1.Buffer.from(message.payload, 'hex').toString()); const sender = buffer_1.Buffer.from(message.sender).toString('hex'); this.log(`📦 Received payload from ${sender}`); // Requests are divided by ID. Each call to sendRequestToOracles creates a new ID, so we can // track the responses for each request separately const pendingRequest = this.pendingRequests.get(payload.id); if (pendingRequest) { if (payload.error) { this.log(`❌ Received error from ${sender}: ${JSON.stringify(payload.error)}`, 'error'); pendingRequest.errorResponses.set(sender, { error: payload.error, sender, }); // Increment the count for this specific error code const errorCode = payload.error.code; const currentCount = (pendingRequest.errorCounts.get(errorCode) || 0) + 1; pendingRequest.errorCounts.set(errorCode, currentCount); // Check if this error type has reached the threshold const errorThresholdCount = Math.ceil(this.oracles.length * this.errorThreshold); if (currentCount >= errorThresholdCount) { clearTimeout(pendingRequest.timer); this.pendingRequests.delete(payload.id); pendingRequest.reject(new Error(`${errorCode}: ${payload.error.message} : ${payload.error.data}`)); return; } } else { pendingRequest.responses.set(sender, { result: payload.result, sender, }); } // If we've received enough responses, resolve the promise if (pendingRequest.responses.size >= pendingRequest.requiredResponses) { clearTimeout(pendingRequest.timer); this.pendingRequests.delete(payload.id); pendingRequest.resolve(Array.from(pendingRequest.responses.values())); } } else { // If we receive a response for a request we're not tracking, ignore it //this.log(`🥱 Received response for untracked request ... ignoring ${payload}`, "warn") } } catch (error) { this.log(`❌ Error parsing message: ${error}`, 'error'); } } // Sends a request to multiple oracles and waits for responses // Returns a promise that resolves when enough responses are received - or rejects on timeout async sendRequestToOracles(method, params, requiredResponses = 0, enableTimeoutError = true) { await this.initPromise; return new Promise((resolve, reject) => { const requestId = (0, uuid_1.v4)(); const responses = new Map(); const errorResponses = new Map(); const errorCounts = new Map(); const timer = setTimeout(() => { if (this.pendingRequests.has(requestId)) { if (enableTimeoutError) { this.pendingRequests.delete(requestId); reject(new Error(`Request timed out after ${this.timeout}ms`)); } else { const collectedResponses = Array.from(responses.values()); this.pendingRequests.delete(requestId); resolve(collectedResponses); } } }, this.timeout); this.pendingRequests.set(requestId, { resolve, reject, timer, responses, errorResponses, errorCounts, requiredResponses, }); this.oracles.forEach((oracle) => { this.sendRequest(method, params, oracle, requestId).catch((error) => { this.log(`❌ Failed to send request to oracle ${oracle}: ${error}`, 'error'); }); }); }); } async sendRequest(method, params, oracle, requestId) { const request = { jsonrpc: '2.0', id: requestId, method, params, }; const message = JSON.stringify(request); this.log(`📤 Sending ${method} request to oracle ${oracle}: ${message}`); await this.client.send(oracle, message); } // Check if all prices in a response are valid isValidResponse(priceInfo) { if (!priceInfo.validation) return false; return Object.values(priceInfo.validation).every((value) => value === true); } validateFetchPricesParams(params) { // Check if pairs is present and has at least one pair if (!params.pairs || params.pairs.length === 0) { throw new Error('Pairs array must contain at least one pair'); } // Check each pair params.pairs.forEach((pair, index) => { if (!pair.from || !pair.to) { throw new Error(`Pair at index ${index} must have 'from' and 'to' fields`); } }); // Check if protocol is present if (!params.protocol) { throw new Error('Protocol field is required'); } // Check if protocol is valid const validProtocols = ['Substrate', 'EVM', 'WASM', 'Tezos']; if (!validProtocols.includes(params.protocol)) { throw new Error(`Invalid protocol: ${params.protocol}`); } // Check minSources against exchanges length if (params.exchanges && params.minSources && params.minSources > params.exchanges.length) { throw new Error(`minSources (${params.minSources}) cannot be greater than the number of exchanges (${params.exchanges.length})`); } // Check tradeAgeLimit if (params.tradeAgeLimit !== undefined && params.tradeAgeLimit <= 0) { throw new Error('tradeAgeLimit must be positive'); } // Check aggregation if (params.aggregation) { const validAggregationTypes = [ 'median', 'mean', 'min', 'max', ]; const aggregations = Array.isArray(params.aggregation) ? params.aggregation : [params.aggregation]; aggregations.forEach((agg) => { if (!validAggregationTypes.includes(agg)) { throw new Error(`Invalid aggregation type: ${agg}`); } }); } // Check maxSourcesDeviation if (params.maxSourcesDeviation !== undefined && params.maxSourcesDeviation <= 0) { throw new Error('maxSourcesDeviation must be positive'); } // Check maxValidationDiff if (params.maxValidationDiff !== undefined && params.maxValidationDiff <= 0) { throw new Error('maxValidationDiff must be positive'); } } /** * Fetches price data from the oracle network. * @param {FetchPricesParams} params - The parameters for fetching prices. * @param {number} verifications - The number of verifications required (default: 0). * @returns {Promise<GetPricesResult[]>} A promise that resolves to an array of combined signed prices. */ async getPrices(params, verifications = 0) { await this.initPromise; this.validateFetchPricesParams(params); const fetchPrices = async (params, requiredResponses, validCheck = false) => { const responses = await this.sendRequestToOracles('fetchPrices', params, requiredResponses); return validCheck ? responses.filter((response) => response.result.priceInfos.every((priceInfo, index) => this.isValidResponse(priceInfo))) : responses; }; const handleInsufficientResponses = (validResponses, required) => { if (validResponses.length < required) { throw new Error(`Only ${validResponses.length} valid responses received, ${required} required`); } }; // If no verifications are required, return the first response if (verifications === 0) { const responses = await fetchPrices(params, 1); handleInsufficientResponses(responses, 1); return this.combineSignedPrices(responses); } // If prices are already provided, skip the initial fetch if (params.pairs.every((pair) => pair.price !== undefined)) { const validResponses = await fetchPrices(params, verifications, true); handleInsufficientResponses(validResponses, verifications); return this.combineSignedPrices(validResponses); } else { // Otherwise, fetch initial prices and use them for verification this.log(`⭐ ${params.pairs.map((pair) => pair.from + '-' + pair.to)} Fetching initial prices for verification...`); const initialResponses = await fetchPrices(params, 1); handleInsufficientResponses(initialResponses, 1); const firstResponse = initialResponses[0].result; this.log(`📬 Initial prices fetched: ${firstResponse}`); const verificationParams = { ...params, pairs: params.pairs.map((pair, index) => ({ ...pair, price: Object.values(firstResponse.priceInfos[index].price), timestamp: firstResponse.priceInfos[index].timestamp, })), }; const validVerifications = await fetchPrices(verificationParams, verifications, true); handleInsufficientResponses(validVerifications, verifications); this.log(`🟢 Verifications: ${validVerifications.length}`); return this.combineSignedPrices(validVerifications); } } combineSignedPrices(responses) { if (responses.length === 0) { return []; } // Use the first response's priceData for each pair const combinedSignedPrices = responses[0].result.signedPrices.map((firstSignedPrice) => { const allSignedPrices = responses.flatMap((response) => response.result.signedPrices .filter((sp) => sp.priceData.from === firstSignedPrice.priceData.from && sp.priceData.to === firstSignedPrice.priceData.to) .map((sp) => ({ ...sp, pubKey: this.idToPubKeyMap[response.sender], }))); return { priceData: firstSignedPrice.priceData, packed: allSignedPrices.map((sp) => sp.packed), signatures: allSignedPrices.map((sp) => sp.signature), pubKeys: allSignedPrices.map((sp) => sp.pubKey), }; }); return combinedSignedPrices; } /** * Retrieves a list of available exchanges. * @param {CheckExchangeHealthParams} params - The parameters for checking exchange health. * @returns {Promise<string[]>} A promise that resolves to an array of available exchange IDs. */ async getExchanges(params, requiredResponses = 0) { return this.sendRequestToOracles('checkExchangeHealth', params || {}, requiredResponses) .then((responses) => { const exchanges = new Set(); responses.forEach((response) => { response.result.healthStatuses .filter((info) => info.status === 'up') .forEach((info) => exchanges.add(info.exchangeId)); }); return Array.from(exchanges); }) .catch((error) => { this.log(`❌ Error checking exchange health: ${error}`, 'error'); throw error; }); } /** * Pings the oracles. * @returns {Promise<{ status: string; timestamp: number, pubKey: string }[]>} A promise that resolves to the list reachable oracles. */ async ping() { await this.initPromise; try { const responses = await this.sendRequestToOracles('ping', {}, this.oracles.length, false); if (responses.length === 0) { throw new Error('No response received from oracles'); } const pingResults = responses.map((response) => ({ status: response.result.status, timestamp: response.result.timestamp, pubKey: this.idToPubKeyMap[response.sender], })); return pingResults; } catch (error) { this.log(`❌ Error pinging oracles: ${error}`, 'error'); throw error; } } /** * Retrieves the list of oracles. * @returns {Promise<string[]>} A promise that resolves to an array of oracle IDs. */ async getOracles() { await this.initPromise; return this.oracles; } /** * Closes the WebSocket connection. * @returns {Promise<void>} A promise that resolves when the connection is closed. */ async close() { await this.initPromise; this.client.close(); } log(message, type = 'default') { switch (type) { case 'warn': console.warn(message); break; case 'error': console.error(message); break; default: if (this.logging) { console.log(message); } } } } exports.AcurastOracleSDK = AcurastOracleSDK; //# sourceMappingURL=index.js.map