[price_service] Let callers specify VAA encoding (#766)

* Callers can specify encoding

* lint

* changed my mind about this one

* cleanup

* cleanup

* bump version
This commit is contained in:
Jayant Krishnamurthy 2023-04-14 11:29:41 -07:00 committed by GitHub
parent 15060d6a5e
commit 6126db74fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 173 additions and 17 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@pythnetwork/price-service-server", "name": "@pythnetwork/price-service-server",
"version": "3.0.0", "version": "3.0.1",
"description": "Webservice for retrieving prices from the Pyth oracle.", "description": "Webservice for retrieving prices from the Pyth oracle.",
"private": "true", "private": "true",
"main": "index.js", "main": "index.js",

View File

@ -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 () => { test("When called with some non-existent ids within ids, returns error mentioning non-existent ids", async () => {
const ids = [ const ids = [
expandTo64Len("ab01"), 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 () => { test("When called with valid ids with leading 0x, returns vaa bytes as array, merged if necessary", async () => {
const ids = [ const ids = [
expandTo64Len("abcd"), 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<VaaConfig>({
vaa: "0x" + Buffer.from("abcd20", "base64").toString("hex"),
publishTime: 20,
});
});
test("When called with invalid id returns price id found", async () => { test("When called with invalid id returns price id found", async () => {
// dead does not exist in the ids // dead does not exist in the ids
const id = expandTo64Len("dead"); const id = expandTo64Len("dead");

View File

@ -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<TargetChain, VaaEncoding> = {
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);
}
}

View File

@ -33,3 +33,13 @@ export function removeLeading0x(s: string): string {
return s; return s;
} }
// Helper for treating T | undefined as an optional value. This lets you pick a
// default if value is undefined.
export function getOrElse<T>(value: T | undefined, defaultValue: T): T {
if (value === undefined) {
return defaultValue;
} else {
return value;
}
}

View File

@ -16,11 +16,24 @@ import { logger } from "./logging";
import { PromClient } from "./promClient"; import { PromClient } from "./promClient";
import { retry } from "ts-retry-promise"; import { retry } from "ts-retry-promise";
import { parseVaa } from "@certusone/wormhole-sdk"; import { parseVaa } from "@certusone/wormhole-sdk";
import { getOrElse } from "./helpers";
import {
TargetChain,
validTargetChains,
defaultTargetChain,
VaaEncoding,
encodeVaaForChain,
} from "./encoding";
const MORGAN_LOG_FORMAT = const MORGAN_LOG_FORMAT =
':remote-addr - :remote-user ":method :url HTTP/:http-version"' + ':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"'; ' :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 { export class RestException extends Error {
statusCode: number; statusCode: number;
message: string; message: string;
@ -144,7 +157,7 @@ export class RestAPI {
priceInfoToJson( priceInfoToJson(
priceInfo: PriceInfo, priceInfo: PriceInfo,
verbose: boolean, verbose: boolean,
binary: boolean targetChain: TargetChain | undefined
): object { ): object {
return { return {
...priceInfo.priceFeed.toJson(), ...priceInfo.priceFeed.toJson(),
@ -156,8 +169,8 @@ export class RestAPI {
price_service_receive_time: priceInfo.priceServiceReceiveTime, price_service_receive_time: priceInfo.priceServiceReceiveTime,
}, },
}), }),
...(binary && { ...(targetChain !== undefined && {
vaa: priceInfo.vaa.toString("base64"), vaa: encodeVaaForChain(priceInfo.vaa, targetChain),
}), }),
}; };
} }
@ -182,6 +195,9 @@ export class RestAPI {
ids: Joi.array() ids: Joi.array()
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/)) .items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
.required(), .required(),
target_chain: Joi.string()
.valid(...validTargetChains)
.optional(),
}).required(), }).required(),
}; };
app.get( app.get(
@ -189,6 +205,10 @@ export class RestAPI {
validate(latestVaasInputSchema), validate(latestVaasInputSchema),
(req: Request, res: Response) => { (req: Request, res: Response) => {
const priceIds = (req.query.ids as string[]).map(removeLeading0x); 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 // 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. // 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) => const jsonResponse = Array.from(vaaMap.values(), (vaa) =>
vaa.toString("base64") encodeVaaForChain(vaa, targetChain)
); );
res.json(jsonResponse); res.json(jsonResponse);
} }
); );
endpoints.push( endpoints.push(
"api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.." `api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&${targetChainArgString}`
); );
const getVaaInputSchema: schema = { const getVaaInputSchema: schema = {
@ -228,6 +248,9 @@ export class RestAPI {
.regex(/^(0x)?[a-f0-9]{64}$/) .regex(/^(0x)?[a-f0-9]{64}$/)
.required(), .required(),
publish_time: Joi.number().required(), publish_time: Joi.number().required(),
target_chain: Joi.string()
.valid(...validTargetChains)
.optional(),
}).required(), }).required(),
}; };
@ -237,6 +260,10 @@ export class RestAPI {
asyncWrapper(async (req: Request, res: Response) => { asyncWrapper(async (req: Request, res: Response) => {
const priceFeedId = removeLeading0x(req.query.id as string); const priceFeedId = removeLeading0x(req.query.id as string);
const publishTime = Number(req.query.publish_time as string); const publishTime = Number(req.query.publish_time as string);
const targetChain = getOrElse(
req.query.target_chain as TargetChain | undefined,
defaultTargetChain
);
if ( if (
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
@ -244,18 +271,21 @@ export class RestAPI {
throw RestException.PriceFeedIdNotFound([priceFeedId]); throw RestException.PriceFeedIdNotFound([priceFeedId]);
} }
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime); const vaaConfig = await this.getVaaWithDbLookup(
priceFeedId,
if (vaa === undefined) { publishTime
);
if (vaaConfig === undefined) {
throw RestException.VaaNotFound(); throw RestException.VaaNotFound();
} else { } else {
res.json(vaa); vaaConfig.vaa = encodeVaaForChain(vaaConfig.vaa, targetChain);
res.json(vaaConfig);
} }
}) })
); );
endpoints.push( endpoints.push(
"api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>" `api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>&${targetChainArgString}`
); );
const getVaaCcipInputSchema: schema = { const getVaaCcipInputSchema: schema = {
@ -317,6 +347,9 @@ export class RestAPI {
.required(), .required(),
verbose: Joi.boolean(), verbose: Joi.boolean(),
binary: Joi.boolean(), binary: Joi.boolean(),
target_chain: Joi.string()
.valid(...validTargetChains)
.optional(),
}).required(), }).required(),
}; };
app.get( app.get(
@ -326,8 +359,12 @@ export class RestAPI {
const priceIds = (req.query.ids as string[]).map(removeLeading0x); const priceIds = (req.query.ids as string[]).map(removeLeading0x);
// verbose is optional, default to false // verbose is optional, default to false
const verbose = req.query.verbose === "true"; const verbose = req.query.verbose === "true";
// binary is optional, default to false // The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
const binary = req.query.binary === "true"; // 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 = []; const responseJson = [];
@ -342,7 +379,7 @@ export class RestAPI {
} }
responseJson.push( responseJson.push(
this.priceInfoToJson(latestPriceInfo, verbose, binary) this.priceInfoToJson(latestPriceInfo, verbose, targetChain)
); );
} }
@ -362,6 +399,9 @@ export class RestAPI {
endpoints.push( endpoints.push(
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&binary=true" "api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&binary=true"
); );
endpoints.push(
`api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&${targetChainArgString}`
);
const getPriceFeedInputSchema: schema = { const getPriceFeedInputSchema: schema = {
query: Joi.object({ query: Joi.object({
@ -371,6 +411,9 @@ export class RestAPI {
publish_time: Joi.number().required(), publish_time: Joi.number().required(),
verbose: Joi.boolean(), verbose: Joi.boolean(),
binary: Joi.boolean(), binary: Joi.boolean(),
target_chain: Joi.string()
.valid(...validTargetChains)
.optional(),
}).required(), }).required(),
}; };
@ -382,8 +425,12 @@ export class RestAPI {
const publishTime = Number(req.query.publish_time as string); const publishTime = Number(req.query.publish_time as string);
// verbose is optional, default to false // verbose is optional, default to false
const verbose = req.query.verbose === "true"; const verbose = req.query.verbose === "true";
// binary is optional, default to false // The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
const binary = req.query.binary === "true"; // 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 ( if (
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
@ -404,7 +451,7 @@ export class RestAPI {
if (priceInfo === undefined) { if (priceInfo === undefined) {
throw RestException.VaaNotFound(); throw RestException.VaaNotFound();
} else { } else {
res.json(this.priceInfoToJson(priceInfo, verbose, binary)); res.json(this.priceInfoToJson(priceInfo, verbose, targetChain));
} }
}) })
); );