@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
JavaScript
;
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