From dbabc3c4fd46f6c72dffa2965ec4049606a28786 Mon Sep 17 00:00:00 2001 From: Arjun Rao <2940142+arjun-io@users.noreply.github.com> Date: Wed, 21 Feb 2024 11:14:39 -0500 Subject: [PATCH] Add v2 endpoints to js client --- .../client/js/src/PriceServiceConnection.ts | 44 ++- .../js/src/__tests__/connection.e2e.test.ts | 39 ++ price_service/sdk/js/src/index.ts | 5 + .../sdk/js/src/schemas/PriceUpdate.ts | 361 ++++++++++++++++++ .../sdk/js/src/schemas/price_update.json | 134 +++++++ 5 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 price_service/sdk/js/src/schemas/PriceUpdate.ts create mode 100644 price_service/sdk/js/src/schemas/price_update.json diff --git a/price_service/client/js/src/PriceServiceConnection.ts b/price_service/client/js/src/PriceServiceConnection.ts index d45058516d..36c9e83e3a 100644 --- a/price_service/client/js/src/PriceServiceConnection.ts +++ b/price_service/client/js/src/PriceServiceConnection.ts @@ -1,4 +1,4 @@ -import { HexString, PriceFeed } from "@pythnetwork/price-service-sdk"; +import { HexString, PriceFeed, PriceUpdate, PriceUpdateConvert } from "@pythnetwork/price-service-sdk"; import axios, { AxiosInstance } from "axios"; import axiosRetry from "axios-retry"; import * as WebSocket from "isomorphic-ws"; @@ -236,6 +236,48 @@ export class PriceServiceConnection { return response.data; } + /** + * Fetch the latest PriceUpdates of the given price ids + * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price ids) + * + * @param priceIds Hex-encoded price ids. + * @returns PriceUpdate + */ + async getLatestPriceUpdates( + priceIds: HexString[], + ): Promise { + const response = await this.httpClient.get("/v2/updates/price/latest", { + params: { + ids: priceIds, + }, + }); + + return PriceUpdateConvert.toPriceUpdate(response.data) + } + + /** + * Fetch the PriceUpdates of the given price ids that is published since the given publish time. + * This will throw an error if the given publish time is in the future, or if the publish time + * is old and the price service endpoint does not have a db backend for historical requests. + * This will throw an axios error if there is a network problem or the price service returns a non-ok response (e.g: Invalid price id) + * + * @param priceIds Hex-encoded price ids. + * @param publishTime Epoch timestamp in seconds. + * @returns PriceUpdate + */ + async getPriceUpdates( + priceIds: HexString[], + publishTime: EpochTimeStamp + ): Promise { + const response = await this.httpClient.get(`/v2/updates/price/${publishTime}`, { + params: { + ids: priceIds, + }, + }); + + return PriceUpdateConvert.toPriceUpdate(response.data) + } + /** * Subscribe to updates for given price ids. * diff --git a/price_service/client/js/src/__tests__/connection.e2e.test.ts b/price_service/client/js/src/__tests__/connection.e2e.test.ts index 5d26d74a8e..84b2511214 100644 --- a/price_service/client/js/src/__tests__/connection.e2e.test.ts +++ b/price_service/client/js/src/__tests__/connection.e2e.test.ts @@ -105,6 +105,45 @@ describe("Test http endpoints", () => { expect(vaa.length).toBeGreaterThan(0); expect(vaaPublishTime).toBeGreaterThanOrEqual(publishTime10SecAgo); }); + + test("Get latest price updates works", async () => { + const connection = new PriceServiceConnection(PRICE_SERVICE_ENDPOINT, { + priceFeedRequestConfig: { binary: true }, + }); + + const ids = await connection.getPriceFeedIds(); + expect(ids.length).toBeGreaterThan(0); + + const update = await connection.getLatestPriceUpdates( + ids.slice(0, 10), + ); + + expect(update.binary.data.length).toBeGreaterThan(0); + expect(update.parsed).toBeDefined(); + expect(update.parsed!.length).toBeGreaterThanOrEqual(0); + }); + + test("Get price updates works", async () => { + const connection = new PriceServiceConnection(PRICE_SERVICE_ENDPOINT, { + priceFeedRequestConfig: { binary: true }, + }); + + const ids = await connection.getPriceFeedIds(); + expect(ids.length).toBeGreaterThan(0); + + const publishTime10SecAgo = Math.floor(new Date().getTime() / 1000) - 10; + const update = await connection.getPriceUpdates( + ids.slice(0, 10), + publishTime10SecAgo + ); + + expect(update.binary.data.length).toBeGreaterThan(0); + expect(update.parsed).toBeDefined(); + expect(update.parsed!.length).toBeGreaterThanOrEqual(0); + for (const parsedUpdate of update.parsed!) { + expect(parsedUpdate.price.publish_time).toBeGreaterThanOrEqual(publishTime10SecAgo) + } + }); }); describe("Test websocket endpoints", () => { diff --git a/price_service/sdk/js/src/index.ts b/price_service/sdk/js/src/index.ts index 3c6ae073ed..feb78df196 100644 --- a/price_service/sdk/js/src/index.ts +++ b/price_service/sdk/js/src/index.ts @@ -5,6 +5,11 @@ import { PriceFeedMetadata as JsonPriceFeedMetadata, } from "./schemas/PriceFeed"; +export { + PriceUpdate, + Convert as PriceUpdateConvert +} from './schemas/PriceUpdate'; + export type UnixTimestamp = number; export type DurationInSeconds = number; export type HexString = string; diff --git a/price_service/sdk/js/src/schemas/PriceUpdate.ts b/price_service/sdk/js/src/schemas/PriceUpdate.ts new file mode 100644 index 0000000000..f8716f4ddc --- /dev/null +++ b/price_service/sdk/js/src/schemas/PriceUpdate.ts @@ -0,0 +1,361 @@ +// To parse this data: +// +// import { Convert, PriceUpdate } from "./file"; +// +// const priceUpdate = Convert.toPriceUpdate(json); +// +// These functions will throw an error if the JSON doesn't +// match the expected interface, even if the JSON is valid. + +/** + * Represents a price update from Pyth publisher feeds. + */ +export interface PriceUpdate { + binary: BinaryPriceUpdate; + parsed?: ParsedPriceUpdate[]; + [property: string]: any; +} + +export interface BinaryPriceUpdate { + data: string[]; + encoding: EncodingType; + [property: string]: any; +} + +export enum EncodingType { + Base64 = "base64", + Hex = "hex", +} + +export interface ParsedPriceUpdate { + ema_price: RPCPrice; + id: string; + metadata: RPCPriceFeedMetadataV2; + price: RPCPrice; + [property: string]: any; +} + +/** + * A price with a degree of uncertainty at a certain time, represented as a price +- a + * confidence + * interval. + * + * The confidence interval roughly corresponds to the standard error of a normal + * distribution. + * Both the price and confidence are stored in a fixed-point numeric representation, `x * + * 10^expo`, where `expo` is the exponent. For example: + */ +export interface RPCPrice { + /** + * The confidence interval associated with the price, stored as a string to avoid precision + * loss + */ + conf: string; + /** + * The exponent associated with both the price and confidence interval. Multiply those + * values + * by `10^expo` to get the real value. + */ + expo: number; + /** + * The price itself, stored as a string to avoid precision loss + */ + price: string; + /** + * When the price was published. The `publish_time` is a unix timestamp, i.e., the number of + * seconds since the Unix epoch (00:00:00 UTC on 1 Jan 1970). + */ + publish_time: number; + [property: string]: any; +} + +export interface RPCPriceFeedMetadataV2 { + prev_publish_time?: number; + proof_available_time?: number; + slot?: number; + [property: string]: any; +} + +// Converts JSON types to/from your types +// and asserts the results at runtime +export class Convert { + public static toPriceUpdate(json: any): PriceUpdate { + return cast(json, r("PriceUpdate")); + } + + public static priceUpdateToJson(value: PriceUpdate): any { + return uncast(value, r("PriceUpdate")); + } + + public static toBinaryPriceUpdate(json: any): BinaryPriceUpdate { + return cast(json, r("BinaryPriceUpdate")); + } + + public static binaryPriceUpdateToJson(value: BinaryPriceUpdate): any { + return uncast(value, r("BinaryPriceUpdate")); + } + + public static toParsedPriceUpdate(json: any): ParsedPriceUpdate { + return cast(json, r("ParsedPriceUpdate")); + } + + public static parsedPriceUpdateToJson(value: ParsedPriceUpdate): any { + return uncast(value, r("ParsedPriceUpdate")); + } + + public static toRPCPrice(json: any): RPCPrice { + return cast(json, r("RPCPrice")); + } + + public static rPCPriceToJson(value: RPCPrice): any { + return uncast(value, r("RPCPrice")); + } + + public static toRPCPriceFeedMetadataV2(json: any): RPCPriceFeedMetadataV2 { + return cast(json, r("RPCPriceFeedMetadataV2")); + } + + public static rPCPriceFeedMetadataV2ToJson( + value: RPCPriceFeedMetadataV2 + ): any { + return uncast(value, r("RPCPriceFeedMetadataV2")); + } +} + +function invalidValue(typ: any, val: any, key: any, parent: any = ""): never { + const prettyTyp = prettyTypeName(typ); + const parentText = parent ? ` on ${parent}` : ""; + const keyText = key ? ` for key "${key}"` : ""; + throw Error( + `Invalid value${keyText}${parentText}. Expected ${prettyTyp} but got ${JSON.stringify( + val + )}` + ); +} + +function prettyTypeName(typ: any): string { + if (Array.isArray(typ)) { + if (typ.length === 2 && typ[0] === undefined) { + return `an optional ${prettyTypeName(typ[1])}`; + } else { + return `one of [${typ + .map((a) => { + return prettyTypeName(a); + }) + .join(", ")}]`; + } + } else if (typeof typ === "object" && typ.literal !== undefined) { + return typ.literal; + } else { + return typeof typ; + } +} + +function jsonToJSProps(typ: any): any { + if (typ.jsonToJS === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => (map[p.json] = { key: p.js, typ: p.typ })); + typ.jsonToJS = map; + } + return typ.jsonToJS; +} + +function jsToJSONProps(typ: any): any { + if (typ.jsToJSON === undefined) { + const map: any = {}; + typ.props.forEach((p: any) => (map[p.js] = { key: p.json, typ: p.typ })); + typ.jsToJSON = map; + } + return typ.jsToJSON; +} + +function transform( + val: any, + typ: any, + getProps: any, + key: any = "", + parent: any = "" +): any { + function transformPrimitive(typ: string, val: any): any { + if (typeof typ === typeof val) return val; + return invalidValue(typ, val, key, parent); + } + + function transformUnion(typs: any[], val: any): any { + // val must validate against one typ in typs + const l = typs.length; + for (let i = 0; i < l; i++) { + const typ = typs[i]; + try { + return transform(val, typ, getProps); + } catch (_) {} + } + return invalidValue(typs, val, key, parent); + } + + function transformEnum(cases: string[], val: any): any { + if (cases.indexOf(val) !== -1) return val; + return invalidValue( + cases.map((a) => { + return l(a); + }), + val, + key, + parent + ); + } + + function transformArray(typ: any, val: any): any { + // val must be an array with no invalid elements + if (!Array.isArray(val)) return invalidValue(l("array"), val, key, parent); + return val.map((el) => transform(el, typ, getProps)); + } + + function transformDate(val: any): any { + if (val === null) { + return null; + } + const d = new Date(val); + if (isNaN(d.valueOf())) { + return invalidValue(l("Date"), val, key, parent); + } + return d; + } + + function transformObject( + props: { [k: string]: any }, + additional: any, + val: any + ): any { + if (val === null || typeof val !== "object" || Array.isArray(val)) { + return invalidValue(l(ref || "object"), val, key, parent); + } + const result: any = {}; + Object.getOwnPropertyNames(props).forEach((key) => { + const prop = props[key]; + const v = Object.prototype.hasOwnProperty.call(val, key) + ? val[key] + : undefined; + result[prop.key] = transform(v, prop.typ, getProps, key, ref); + }); + Object.getOwnPropertyNames(val).forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + result[key] = transform(val[key], additional, getProps, key, ref); + } + }); + return result; + } + + if (typ === "any") return val; + if (typ === null) { + if (val === null) return val; + return invalidValue(typ, val, key, parent); + } + if (typ === false) return invalidValue(typ, val, key, parent); + let ref: any = undefined; + while (typeof typ === "object" && typ.ref !== undefined) { + ref = typ.ref; + typ = typeMap[typ.ref]; + } + if (Array.isArray(typ)) return transformEnum(typ, val); + if (typeof typ === "object") { + return typ.hasOwnProperty("unionMembers") + ? transformUnion(typ.unionMembers, val) + : typ.hasOwnProperty("arrayItems") + ? transformArray(typ.arrayItems, val) + : typ.hasOwnProperty("props") + ? transformObject(getProps(typ), typ.additional, val) + : invalidValue(typ, val, key, parent); + } + // Numbers can be parsed by Date but shouldn't be. + if (typ === Date && typeof val !== "number") return transformDate(val); + return transformPrimitive(typ, val); +} + +function cast(val: any, typ: any): T { + return transform(val, typ, jsonToJSProps); +} + +function uncast(val: T, typ: any): any { + return transform(val, typ, jsToJSONProps); +} + +function l(typ: any) { + return { literal: typ }; +} + +function a(typ: any) { + return { arrayItems: typ }; +} + +function u(...typs: any[]) { + return { unionMembers: typs }; +} + +function o(props: any[], additional: any) { + return { props, additional }; +} + +function m(additional: any) { + return { props: [], additional }; +} + +function r(name: string) { + return { ref: name }; +} + +const typeMap: any = { + PriceUpdate: o( + [ + { json: "binary", js: "binary", typ: r("BinaryPriceUpdate") }, + { + json: "parsed", + js: "parsed", + typ: u(undefined, a(r("ParsedPriceUpdate"))), + }, + ], + "any" + ), + BinaryPriceUpdate: o( + [ + { json: "data", js: "data", typ: a("") }, + { json: "encoding", js: "encoding", typ: r("EncodingType") }, + ], + "any" + ), + ParsedPriceUpdate: o( + [ + { json: "ema_price", js: "ema_price", typ: r("RPCPrice") }, + { json: "id", js: "id", typ: "" }, + { json: "metadata", js: "metadata", typ: r("RPCPriceFeedMetadataV2") }, + { json: "price", js: "price", typ: r("RPCPrice") }, + ], + "any" + ), + RPCPrice: o( + [ + { json: "conf", js: "conf", typ: "" }, + { json: "expo", js: "expo", typ: 0 }, + { json: "price", js: "price", typ: "" }, + { json: "publish_time", js: "publish_time", typ: 0 }, + ], + "any" + ), + RPCPriceFeedMetadataV2: o( + [ + { + json: "prev_publish_time", + js: "prev_publish_time", + typ: u(undefined, 0), + }, + { + json: "proof_available_time", + js: "proof_available_time", + typ: u(undefined, 0), + }, + { json: "slot", js: "slot", typ: u(undefined, 0) }, + ], + "any" + ), + EncodingType: ["base64", "hex"], +}; diff --git a/price_service/sdk/js/src/schemas/price_update.json b/price_service/sdk/js/src/schemas/price_update.json new file mode 100644 index 0000000000..c1023e12ee --- /dev/null +++ b/price_service/sdk/js/src/schemas/price_update.json @@ -0,0 +1,134 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PriceUpdate", + "description": "Represents a price update from Pyth publisher feeds.", + "type": "object", + "required": [ + "binary" + ], + "properties": { + "binary": { + "$ref": "#/definitions/BinaryPriceUpdate" + }, + "parsed": { + "type": "array", + "items": { + "$ref": "#/definitions/ParsedPriceUpdate" + }, + "nullable": true + } + }, + + "definitions": { + "BinaryPriceUpdate": { + "type": "object", + "required": [ + "encoding", + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "encoding": { + "$ref": "#/definitions/EncodingType" + } + } + }, + "EncodingType": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + }, + "ParsedPriceUpdate": { + "type": "object", + "required": [ + "id", + "price", + "ema_price", + "metadata" + ], + "properties": { + "ema_price": { + "$ref": "#/definitions/RpcPrice" + }, + "id": { + "$ref": "#/definitions/RpcPriceIdentifier" + }, + "metadata": { + "$ref": "#/definitions/RpcPriceFeedMetadataV2" + }, + "price": { + "$ref": "#/definitions/RpcPrice" + } + } + }, + "RpcPrice": { + "type": "object", + "description": "A price with a degree of uncertainty at a certain time, represented as a price +- a confidence\ninterval.\n\nThe confidence interval roughly corresponds to the standard error of a normal distribution.\nBoth the price and confidence are stored in a fixed-point numeric representation, `x *\n10^expo`, where `expo` is the exponent. For example:", + "required": [ + "price", + "conf", + "expo", + "publish_time" + ], + "properties": { + "conf": { + "type": "string", + "description": "The confidence interval associated with the price, stored as a string to avoid precision loss", + "example": "509500001" + }, + "expo": { + "type": "integer", + "format": "int32", + "description": "The exponent associated with both the price and confidence interval. Multiply those values\nby `10^expo` to get the real value.", + "example": -8 + }, + "price": { + "type": "string", + "description": "The price itself, stored as a string to avoid precision loss", + "example": "2920679499999" + }, + "publish_time": { + "type": "integer", + "format": "int64", + "description": "When the price was published. The `publish_time` is a unix timestamp, i.e., the number of\nseconds since the Unix epoch (00:00:00 UTC on 1 Jan 1970).", + "example": 1708363376 + } + } + }, + "RpcPriceIdentifier": { + "type": "string", + "example": "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" + }, + "RpcPriceFeedMetadataV2": { + "type": "object", + "properties": { + "prev_publish_time": { + "type": "integer", + "format": "int64", + "example": 1708363376, + "nullable": true + }, + "proof_available_time": { + "type": "integer", + "format": "int64", + "example": 1708363376, + "nullable": true + }, + "slot": { + "type": "integer", + "format": "int64", + "example": 85480034, + "nullable": true, + "minimum": 0 + } + } + } + } +}