Skip to content

Commit 6126db7

Browse files
authored
[price_service] Let callers specify VAA encoding (#766)
* Callers can specify encoding * lint * changed my mind about this one * cleanup * cleanup * bump version
1 parent 15060d6 commit 6126db7

File tree

5 files changed

+173
-17
lines changed

5 files changed

+173
-17
lines changed

price_service/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pythnetwork/price-service-server",
3-
"version": "3.0.0",
3+
"version": "3.0.1",
44
"description": "Webservice for retrieving prices from the Pyth oracle.",
55
"private": "true",
66
"main": "index.js",

price_service/server/src/__tests__/rest.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,23 @@ describe("Latest Price Feed Endpoint", () => {
152152
});
153153
});
154154

155+
test("When called with a target_chain, returns correct price feed with binary vaa encoded properly", async () => {
156+
const ids = [expandTo64Len("abcd"), expandTo64Len("3456")];
157+
const resp = await request(app)
158+
.get("/api/latest_price_feeds")
159+
.query({ ids, target_chain: "evm" });
160+
expect(resp.status).toBe(StatusCodes.OK);
161+
expect(resp.body.length).toBe(2);
162+
expect(resp.body).toContainEqual({
163+
...priceInfoMap.get(ids[0])!.priceFeed.toJson(),
164+
vaa: "0x" + priceInfoMap.get(ids[0])!.vaa.toString("hex"),
165+
});
166+
expect(resp.body).toContainEqual({
167+
...priceInfoMap.get(ids[1])!.priceFeed.toJson(),
168+
vaa: "0x" + priceInfoMap.get(ids[1])!.vaa.toString("hex"),
169+
});
170+
});
171+
155172
test("When called with some non-existent ids within ids, returns error mentioning non-existent ids", async () => {
156173
const ids = [
157174
expandTo64Len("ab01"),
@@ -186,6 +203,21 @@ describe("Latest Vaa Bytes Endpoint", () => {
186203
);
187204
});
188205

206+
test("When called with target_chain, returns vaa bytes encoded correctly", async () => {
207+
const ids = [
208+
expandTo64Len("abcd"),
209+
expandTo64Len("ef01"),
210+
expandTo64Len("3456"),
211+
];
212+
const resp = await request(app)
213+
.get("/api/latest_vaas")
214+
.query({ ids, target_chain: "evm" });
215+
expect(resp.status).toBe(StatusCodes.OK);
216+
expect(resp.body.length).toBe(2);
217+
expect(resp.body).toContain("0xa1b2c3d4");
218+
expect(resp.body).toContain("0xbad01bad");
219+
});
220+
189221
test("When called with valid ids with leading 0x, returns vaa bytes as array, merged if necessary", async () => {
190222
const ids = [
191223
expandTo64Len("abcd"),
@@ -271,6 +303,26 @@ describe("Get VAA endpoint and Get VAA CCIP", () => {
271303
});
272304
});
273305

306+
test("When called with target_chain, encodes resulting VAA in the right format", async () => {
307+
const id = expandTo64Len("abcd");
308+
vaasCache.set(id, 10, "abcd10");
309+
vaasCache.set(id, 20, "abcd20");
310+
vaasCache.set(id, 30, "abcd30");
311+
312+
const resp = await request(app)
313+
.get("/api/get_vaa")
314+
.query({
315+
id: "0x" + id,
316+
publish_time: 16,
317+
target_chain: "evm",
318+
});
319+
expect(resp.status).toBe(StatusCodes.OK);
320+
expect(resp.body).toEqual<VaaConfig>({
321+
vaa: "0x" + Buffer.from("abcd20", "base64").toString("hex"),
322+
publishTime: 20,
323+
});
324+
});
325+
274326
test("When called with invalid id returns price id found", async () => {
275327
// dead does not exist in the ids
276328
const id = expandTo64Len("dead");

price_service/server/src/encoding.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Utilities for encoding VAAs for specific target chains
2+
3+
// List of all possible target chains. Note that "default" is an option because we need at least one chain
4+
// with a base64 encoding (which is the old default behavior of all API methods).
5+
export type TargetChain = "evm" | "cosmos" | "aptos" | "default";
6+
export const validTargetChains = ["evm", "cosmos", "aptos", "default"];
7+
export const defaultTargetChain: TargetChain = "default";
8+
9+
// Possible encodings of the binary VAA data as a string.
10+
// "0x" is the same as "hex" with a leading "0x" prepended to the hex string.
11+
export type VaaEncoding = "base64" | "hex" | "0x";
12+
export const defaultVaaEncoding: VaaEncoding = "base64";
13+
export const chainToEncoding: Record<TargetChain, VaaEncoding> = {
14+
evm: "0x",
15+
cosmos: "base64",
16+
// TODO: I think aptos actually wants a number[] for this data... need to decide how to
17+
// handle that case.
18+
aptos: "base64",
19+
default: "base64",
20+
};
21+
22+
// Given a VAA represented as either a string in base64 or a Buffer, encode it as a string
23+
// appropriate for the given targetChain.
24+
export function encodeVaaForChain(
25+
vaa: string | Buffer,
26+
targetChain: TargetChain
27+
): string {
28+
const encoding = chainToEncoding[targetChain];
29+
30+
let vaaBuffer: Buffer;
31+
if (typeof vaa === "string") {
32+
if (encoding === defaultVaaEncoding) {
33+
return vaa;
34+
} else {
35+
vaaBuffer = Buffer.from(vaa, defaultVaaEncoding as BufferEncoding);
36+
}
37+
} else {
38+
vaaBuffer = vaa;
39+
}
40+
41+
switch (encoding) {
42+
case "0x":
43+
return "0x" + vaaBuffer.toString("hex");
44+
default:
45+
return vaaBuffer.toString(encoding);
46+
}
47+
}

price_service/server/src/helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,13 @@ export function removeLeading0x(s: string): string {
3333

3434
return s;
3535
}
36+
37+
// Helper for treating T | undefined as an optional value. This lets you pick a
38+
// default if value is undefined.
39+
export function getOrElse<T>(value: T | undefined, defaultValue: T): T {
40+
if (value === undefined) {
41+
return defaultValue;
42+
} else {
43+
return value;
44+
}
45+
}

price_service/server/src/rest.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,24 @@ import { logger } from "./logging";
1616
import { PromClient } from "./promClient";
1717
import { retry } from "ts-retry-promise";
1818
import { parseVaa } from "@certusone/wormhole-sdk";
19+
import { getOrElse } from "./helpers";
20+
import {
21+
TargetChain,
22+
validTargetChains,
23+
defaultTargetChain,
24+
VaaEncoding,
25+
encodeVaaForChain,
26+
} from "./encoding";
1927

2028
const MORGAN_LOG_FORMAT =
2129
':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
2230
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"';
2331

32+
// GET argument string to represent the options for target_chain
33+
export const targetChainArgString = `target_chain=<${validTargetChains.join(
34+
"|"
35+
)}>`;
36+
2437
export class RestException extends Error {
2538
statusCode: number;
2639
message: string;
@@ -144,7 +157,7 @@ export class RestAPI {
144157
priceInfoToJson(
145158
priceInfo: PriceInfo,
146159
verbose: boolean,
147-
binary: boolean
160+
targetChain: TargetChain | undefined
148161
): object {
149162
return {
150163
...priceInfo.priceFeed.toJson(),
@@ -156,8 +169,8 @@ export class RestAPI {
156169
price_service_receive_time: priceInfo.priceServiceReceiveTime,
157170
},
158171
}),
159-
...(binary && {
160-
vaa: priceInfo.vaa.toString("base64"),
172+
...(targetChain !== undefined && {
173+
vaa: encodeVaaForChain(priceInfo.vaa, targetChain),
161174
}),
162175
};
163176
}
@@ -182,13 +195,20 @@ export class RestAPI {
182195
ids: Joi.array()
183196
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
184197
.required(),
198+
target_chain: Joi.string()
199+
.valid(...validTargetChains)
200+
.optional(),
185201
}).required(),
186202
};
187203
app.get(
188204
"/api/latest_vaas",
189205
validate(latestVaasInputSchema),
190206
(req: Request, res: Response) => {
191207
const priceIds = (req.query.ids as string[]).map(removeLeading0x);
208+
const targetChain = getOrElse(
209+
req.query.target_chain as TargetChain | undefined,
210+
defaultTargetChain
211+
);
192212

193213
// Multiple price ids might share same vaa, we use sequence number as
194214
// key of a vaa and deduplicate using a map of seqnum to vaa bytes.
@@ -212,14 +232,14 @@ export class RestAPI {
212232
}
213233

214234
const jsonResponse = Array.from(vaaMap.values(), (vaa) =>
215-
vaa.toString("base64")
235+
encodeVaaForChain(vaa, targetChain)
216236
);
217237

218238
res.json(jsonResponse);
219239
}
220240
);
221241
endpoints.push(
222-
"api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
242+
`api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&${targetChainArgString}`
223243
);
224244

225245
const getVaaInputSchema: schema = {
@@ -228,6 +248,9 @@ export class RestAPI {
228248
.regex(/^(0x)?[a-f0-9]{64}$/)
229249
.required(),
230250
publish_time: Joi.number().required(),
251+
target_chain: Joi.string()
252+
.valid(...validTargetChains)
253+
.optional(),
231254
}).required(),
232255
};
233256

@@ -237,25 +260,32 @@ export class RestAPI {
237260
asyncWrapper(async (req: Request, res: Response) => {
238261
const priceFeedId = removeLeading0x(req.query.id as string);
239262
const publishTime = Number(req.query.publish_time as string);
263+
const targetChain = getOrElse(
264+
req.query.target_chain as TargetChain | undefined,
265+
defaultTargetChain
266+
);
240267

241268
if (
242269
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
243270
) {
244271
throw RestException.PriceFeedIdNotFound([priceFeedId]);
245272
}
246273

247-
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime);
248-
249-
if (vaa === undefined) {
274+
const vaaConfig = await this.getVaaWithDbLookup(
275+
priceFeedId,
276+
publishTime
277+
);
278+
if (vaaConfig === undefined) {
250279
throw RestException.VaaNotFound();
251280
} else {
252-
res.json(vaa);
281+
vaaConfig.vaa = encodeVaaForChain(vaaConfig.vaa, targetChain);
282+
res.json(vaaConfig);
253283
}
254284
})
255285
);
256286

257287
endpoints.push(
258-
"api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>"
288+
`api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>&${targetChainArgString}`
259289
);
260290

261291
const getVaaCcipInputSchema: schema = {
@@ -317,6 +347,9 @@ export class RestAPI {
317347
.required(),
318348
verbose: Joi.boolean(),
319349
binary: Joi.boolean(),
350+
target_chain: Joi.string()
351+
.valid(...validTargetChains)
352+
.optional(),
320353
}).required(),
321354
};
322355
app.get(
@@ -326,8 +359,12 @@ export class RestAPI {
326359
const priceIds = (req.query.ids as string[]).map(removeLeading0x);
327360
// verbose is optional, default to false
328361
const verbose = req.query.verbose === "true";
329-
// binary is optional, default to false
330-
const binary = req.query.binary === "true";
362+
// The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
363+
// No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default
364+
let targetChain = req.query.target_chain as TargetChain | undefined;
365+
if (targetChain === undefined && req.query.binary === "true") {
366+
targetChain = defaultTargetChain;
367+
}
331368

332369
const responseJson = [];
333370

@@ -342,7 +379,7 @@ export class RestAPI {
342379
}
343380

344381
responseJson.push(
345-
this.priceInfoToJson(latestPriceInfo, verbose, binary)
382+
this.priceInfoToJson(latestPriceInfo, verbose, targetChain)
346383
);
347384
}
348385

@@ -362,6 +399,9 @@ export class RestAPI {
362399
endpoints.push(
363400
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&binary=true"
364401
);
402+
endpoints.push(
403+
`api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true&${targetChainArgString}`
404+
);
365405

366406
const getPriceFeedInputSchema: schema = {
367407
query: Joi.object({
@@ -371,6 +411,9 @@ export class RestAPI {
371411
publish_time: Joi.number().required(),
372412
verbose: Joi.boolean(),
373413
binary: Joi.boolean(),
414+
target_chain: Joi.string()
415+
.valid(...validTargetChains)
416+
.optional(),
374417
}).required(),
375418
};
376419

@@ -382,8 +425,12 @@ export class RestAPI {
382425
const publishTime = Number(req.query.publish_time as string);
383426
// verbose is optional, default to false
384427
const verbose = req.query.verbose === "true";
385-
// binary is optional, default to false
386-
const binary = req.query.binary === "true";
428+
// The binary and target_chain are somewhat redundant. Binary still exists for backward compatibility reasons.
429+
// No VAA will be returned if both arguments are omitted. binary=true is the same as target_chain=default
430+
let targetChain = req.query.target_chain as TargetChain | undefined;
431+
if (targetChain === undefined && req.query.binary === "true") {
432+
targetChain = defaultTargetChain;
433+
}
387434

388435
if (
389436
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
@@ -404,7 +451,7 @@ export class RestAPI {
404451
if (priceInfo === undefined) {
405452
throw RestException.VaaNotFound();
406453
} else {
407-
res.json(this.priceInfoToJson(priceInfo, verbose, binary));
454+
res.json(this.priceInfoToJson(priceInfo, verbose, targetChain));
408455
}
409456
})
410457
);

0 commit comments

Comments
 (0)