[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:
parent
15060d6a5e
commit
6126db74fc
|
@ -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",
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue