From 6126db74fcf8040048a9e970d65ff8a62d317848 Mon Sep 17 00:00:00 2001 From: Jayant Krishnamurthy Date: Fri, 14 Apr 2023 11:29:41 -0700 Subject: [PATCH] [price_service] Let callers specify VAA encoding (#766) * Callers can specify encoding * lint * changed my mind about this one * cleanup * cleanup * bump version --- price_service/server/package.json | 2 +- .../server/src/__tests__/rest.test.ts | 52 ++++++++++++ price_service/server/src/encoding.ts | 47 +++++++++++ price_service/server/src/helpers.ts | 10 +++ price_service/server/src/rest.ts | 79 +++++++++++++++---- 5 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 price_service/server/src/encoding.ts diff --git a/price_service/server/package.json b/price_service/server/package.json index ff8c16ff..b8f8ec4d 100644 --- a/price_service/server/package.json +++ b/price_service/server/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/price-service-server", - "version": "3.0.0", + "version": "3.0.1", "description": "Webservice for retrieving prices from the Pyth oracle.", "private": "true", "main": "index.js", diff --git a/price_service/server/src/__tests__/rest.test.ts b/price_service/server/src/__tests__/rest.test.ts index 91a02cae..a6faa7ce 100644 --- a/price_service/server/src/__tests__/rest.test.ts +++ b/price_service/server/src/__tests__/rest.test.ts @@ -152,6 +152,23 @@ describe("Latest Price Feed Endpoint", () => { }); }); + test("When called with a target_chain, returns correct price feed with binary vaa encoded properly", async () => { + const ids = [expandTo64Len("abcd"), expandTo64Len("3456")]; + const resp = await request(app) + .get("/api/latest_price_feeds") + .query({ ids, target_chain: "evm" }); + expect(resp.status).toBe(StatusCodes.OK); + expect(resp.body.length).toBe(2); + expect(resp.body).toContainEqual({ + ...priceInfoMap.get(ids[0])!.priceFeed.toJson(), + vaa: "0x" + priceInfoMap.get(ids[0])!.vaa.toString("hex"), + }); + expect(resp.body).toContainEqual({ + ...priceInfoMap.get(ids[1])!.priceFeed.toJson(), + vaa: "0x" + priceInfoMap.get(ids[1])!.vaa.toString("hex"), + }); + }); + test("When called with some non-existent ids within ids, returns error mentioning non-existent ids", async () => { const ids = [ expandTo64Len("ab01"), @@ -186,6 +203,21 @@ describe("Latest Vaa Bytes Endpoint", () => { ); }); + test("When called with target_chain, returns vaa bytes encoded correctly", async () => { + const ids = [ + expandTo64Len("abcd"), + expandTo64Len("ef01"), + expandTo64Len("3456"), + ]; + const resp = await request(app) + .get("/api/latest_vaas") + .query({ ids, target_chain: "evm" }); + expect(resp.status).toBe(StatusCodes.OK); + expect(resp.body.length).toBe(2); + expect(resp.body).toContain("0xa1b2c3d4"); + expect(resp.body).toContain("0xbad01bad"); + }); + test("When called with valid ids with leading 0x, returns vaa bytes as array, merged if necessary", async () => { const ids = [ expandTo64Len("abcd"), @@ -271,6 +303,26 @@ describe("Get VAA endpoint and Get VAA CCIP", () => { }); }); + test("When called with target_chain, encodes resulting VAA in the right format", async () => { + const id = expandTo64Len("abcd"); + vaasCache.set(id, 10, "abcd10"); + vaasCache.set(id, 20, "abcd20"); + vaasCache.set(id, 30, "abcd30"); + + const resp = await request(app) + .get("/api/get_vaa") + .query({ + id: "0x" + id, + publish_time: 16, + target_chain: "evm", + }); + expect(resp.status).toBe(StatusCodes.OK); + expect(resp.body).toEqual({ + vaa: "0x" + Buffer.from("abcd20", "base64").toString("hex"), + publishTime: 20, + }); + }); + test("When called with invalid id returns price id found", async () => { // dead does not exist in the ids const id = expandTo64Len("dead"); diff --git a/price_service/server/src/encoding.ts b/price_service/server/src/encoding.ts new file mode 100644 index 00000000..e453119e --- /dev/null +++ b/price_service/server/src/encoding.ts @@ -0,0 +1,47 @@ +// Utilities for encoding VAAs for specific target chains + +// List of all possible target chains. Note that "default" is an option because we need at least one chain +// with a base64 encoding (which is the old default behavior of all API methods). +export type TargetChain = "evm" | "cosmos" | "aptos" | "default"; +export const validTargetChains = ["evm", "cosmos", "aptos", "default"]; +export const defaultTargetChain: TargetChain = "default"; + +// Possible encodings of the binary VAA data as a string. +// "0x" is the same as "hex" with a leading "0x" prepended to the hex string. +export type VaaEncoding = "base64" | "hex" | "0x"; +export const defaultVaaEncoding: VaaEncoding = "base64"; +export const chainToEncoding: Record = { + evm: "0x", + cosmos: "base64", + // TODO: I think aptos actually wants a number[] for this data... need to decide how to + // handle that case. + aptos: "base64", + default: "base64", +}; + +// Given a VAA represented as either a string in base64 or a Buffer, encode it as a string +// appropriate for the given targetChain. +export function encodeVaaForChain( + vaa: string | Buffer, + targetChain: TargetChain +): string { + const encoding = chainToEncoding[targetChain]; + + let vaaBuffer: Buffer; + if (typeof vaa === "string") { + if (encoding === defaultVaaEncoding) { + return vaa; + } else { + vaaBuffer = Buffer.from(vaa, defaultVaaEncoding as BufferEncoding); + } + } else { + vaaBuffer = vaa; + } + + switch (encoding) { + case "0x": + return "0x" + vaaBuffer.toString("hex"); + default: + return vaaBuffer.toString(encoding); + } +} diff --git a/price_service/server/src/helpers.ts b/price_service/server/src/helpers.ts index 068b414a..a5a3abdc 100644 --- a/price_service/server/src/helpers.ts +++ b/price_service/server/src/helpers.ts @@ -33,3 +33,13 @@ export function removeLeading0x(s: string): string { return s; } + +// Helper for treating T | undefined as an optional value. This lets you pick a +// default if value is undefined. +export function getOrElse(value: T | undefined, defaultValue: T): T { + if (value === undefined) { + return defaultValue; + } else { + return value; + } +} diff --git a/price_service/server/src/rest.ts b/price_service/server/src/rest.ts index f5222eb6..b5e71965 100644 --- a/price_service/server/src/rest.ts +++ b/price_service/server/src/rest.ts @@ -16,11 +16,24 @@ import { logger } from "./logging"; import { PromClient } from "./promClient"; import { retry } from "ts-retry-promise"; import { parseVaa } from "@certusone/wormhole-sdk"; +import { getOrElse } from "./helpers"; +import { + TargetChain, + validTargetChains, + defaultTargetChain, + VaaEncoding, + encodeVaaForChain, +} from "./encoding"; const MORGAN_LOG_FORMAT = ':remote-addr - :remote-user ":method :url HTTP/:http-version"' + ' :status :res[content-length] :response-time ms ":referrer" ":user-agent"'; +// GET argument string to represent the options for target_chain +export const targetChainArgString = `target_chain=<${validTargetChains.join( + "|" +)}>`; + export class RestException extends Error { statusCode: number; message: string; @@ -144,7 +157,7 @@ export class RestAPI { priceInfoToJson( priceInfo: PriceInfo, verbose: boolean, - binary: boolean + targetChain: TargetChain | undefined ): object { return { ...priceInfo.priceFeed.toJson(), @@ -156,8 +169,8 @@ export class RestAPI { price_service_receive_time: priceInfo.priceServiceReceiveTime, }, }), - ...(binary && { - vaa: priceInfo.vaa.toString("base64"), + ...(targetChain !== undefined && { + vaa: encodeVaaForChain(priceInfo.vaa, targetChain), }), }; } @@ -182,6 +195,9 @@ export class RestAPI { ids: Joi.array() .items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/)) .required(), + target_chain: Joi.string() + .valid(...validTargetChains) + .optional(), }).required(), }; app.get( @@ -189,6 +205,10 @@ export class RestAPI { validate(latestVaasInputSchema), (req: Request, res: Response) => { const priceIds = (req.query.ids as string[]).map(removeLeading0x); + const targetChain = getOrElse( + req.query.target_chain as TargetChain | undefined, + defaultTargetChain + ); // Multiple price ids might share same vaa, we use sequence number as // key of a vaa and deduplicate using a map of seqnum to vaa bytes. @@ -212,14 +232,14 @@ export class RestAPI { } const jsonResponse = Array.from(vaaMap.values(), (vaa) => - vaa.toString("base64") + encodeVaaForChain(vaa, targetChain) ); res.json(jsonResponse); } ); endpoints.push( - "api/latest_vaas?ids[]=&ids[]=&.." + `api/latest_vaas?ids[]=&ids[]=&..&${targetChainArgString}` ); const getVaaInputSchema: schema = { @@ -228,6 +248,9 @@ export class RestAPI { .regex(/^(0x)?[a-f0-9]{64}$/) .required(), publish_time: Joi.number().required(), + target_chain: Joi.string() + .valid(...validTargetChains) + .optional(), }).required(), }; @@ -237,6 +260,10 @@ export class RestAPI { asyncWrapper(async (req: Request, res: Response) => { const priceFeedId = removeLeading0x(req.query.id as string); const publishTime = Number(req.query.publish_time as string); + const targetChain = getOrElse( + req.query.target_chain as TargetChain | undefined, + defaultTargetChain + ); if ( this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined @@ -244,18 +271,21 @@ export class RestAPI { throw RestException.PriceFeedIdNotFound([priceFeedId]); } - const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime); - - if (vaa === undefined) { + const vaaConfig = await this.getVaaWithDbLookup( + priceFeedId, + publishTime + ); + if (vaaConfig === undefined) { throw RestException.VaaNotFound(); } else { - res.json(vaa); + vaaConfig.vaa = encodeVaaForChain(vaaConfig.vaa, targetChain); + res.json(vaaConfig); } }) ); endpoints.push( - "api/get_vaa?id=&publish_time=" + `api/get_vaa?id=&publish_time=&${targetChainArgString}` ); const getVaaCcipInputSchema: schema = { @@ -317,6 +347,9 @@ export class RestAPI { .required(), verbose: Joi.boolean(), binary: Joi.boolean(), + target_chain: Joi.string() + .valid(...validTargetChains) + .optional(), }).required(), }; app.get( @@ -326,8 +359,12 @@ export class RestAPI { const priceIds = (req.query.ids as string[]).map(removeLeading0x); // verbose is optional, default to false const verbose = req.query.verbose === "true"; - // binary is optional, default to false - const binary = req.query.binary === "true"; + // The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons. + // No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default + let targetChain = req.query.target_chain as TargetChain | undefined; + if (targetChain === undefined && req.query.binary === "true") { + targetChain = defaultTargetChain; + } const responseJson = []; @@ -342,7 +379,7 @@ export class RestAPI { } responseJson.push( - this.priceInfoToJson(latestPriceInfo, verbose, binary) + this.priceInfoToJson(latestPriceInfo, verbose, targetChain) ); } @@ -362,6 +399,9 @@ export class RestAPI { endpoints.push( "api/latest_price_feeds?ids[]=&ids[]=&..&verbose=true&binary=true" ); + endpoints.push( + `api/latest_price_feeds?ids[]=&ids[]=&..&verbose=true&${targetChainArgString}` + ); const getPriceFeedInputSchema: schema = { query: Joi.object({ @@ -371,6 +411,9 @@ export class RestAPI { publish_time: Joi.number().required(), verbose: Joi.boolean(), binary: Joi.boolean(), + target_chain: Joi.string() + .valid(...validTargetChains) + .optional(), }).required(), }; @@ -382,8 +425,12 @@ export class RestAPI { const publishTime = Number(req.query.publish_time as string); // verbose is optional, default to false const verbose = req.query.verbose === "true"; - // binary is optional, default to false - const binary = req.query.binary === "true"; + // The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons. + // No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default + let targetChain = req.query.target_chain as TargetChain | undefined; + if (targetChain === undefined && req.query.binary === "true") { + targetChain = defaultTargetChain; + } if ( this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined @@ -404,7 +451,7 @@ export class RestAPI { if (priceInfo === undefined) { throw RestException.VaaNotFound(); } else { - res.json(this.priceInfoToJson(priceInfo, verbose, binary)); + res.json(this.priceInfoToJson(priceInfo, verbose, targetChain)); } }) );