Skip to content

Commit 5e8b9f8

Browse files
authored
[price-service] Add get_vaa_ccip endpoint (#500)
* [price-service] Refactor + bugfix * [price-service] Add get_vaa_ccip endpoint * [price-service] Refactor + add retry * [price-service] Address feedback + handle api error
1 parent 9d2bd87 commit 5e8b9f8

File tree

6 files changed

+183
-41
lines changed

6 files changed

+183
-41
lines changed

price-service/package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

price-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"node-fetch": "^2.6.1",
5151
"prom-client": "^14.0.1",
5252
"response-time": "^2.3.2",
53+
"ts-retry-promise": "^0.7.0",
5354
"winston": "^3.3.3",
5455
"ws": "^8.6.0"
5556
},

price-service/src/helpers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,21 @@ export function envOrErr(env: string): string {
1515
}
1616
return String(process.env[env]);
1717
}
18+
19+
export function parseToOptionalNumber(
20+
s: string | undefined
21+
): number | undefined {
22+
if (s === undefined) {
23+
return undefined;
24+
}
25+
26+
return parseInt(s, 10);
27+
}
28+
29+
export function removeLeading0x(s: string): string {
30+
if (s.startsWith("0x")) {
31+
return s.substring(2);
32+
}
33+
34+
return s;
35+
}

price-service/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { envOrErr } from "./helpers";
1+
import { envOrErr, parseToOptionalNumber } from "./helpers";
22
import { Listener } from "./listen";
33
import { initLogger } from "./logging";
44
import { PromClient } from "./promClient";
@@ -38,6 +38,10 @@ async function run() {
3838
10
3939
),
4040
},
41+
cacheCleanupLoopInterval: parseToOptionalNumber(
42+
process.env.REMOVE_EXPIRED_VALUES_INTERVAL_SECONDS
43+
),
44+
cacheTtl: parseToOptionalNumber(process.env.CACHE_TTL_SECONDS),
4145
},
4246
promClient
4347
);
@@ -59,7 +63,7 @@ async function run() {
5963
const wsAPI = new WebSocketAPI(listener, promClient);
6064

6165
listener.run();
62-
listener.runCacheCleanupLoop();
66+
6367
const server = await restAPI.run();
6468
wsAPI.run(server);
6569
}

price-service/src/listen.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from "@pythnetwork/p2w-sdk-js";
1818
import { HexString, PriceFeed } from "@pythnetwork/pyth-sdk-js";
1919
import LRUCache from "lru-cache";
20-
import { sleep, TimestampInSec } from "./helpers";
20+
import { DurationInSec, sleep, TimestampInSec } from "./helpers";
2121
import { logger } from "./logging";
2222
import { PromClient } from "./promClient";
2323

@@ -49,11 +49,13 @@ type ListenerConfig = {
4949
readiness: ListenerReadinessConfig;
5050
webApiEndpoint?: string;
5151
webApiCluster?: string;
52+
cacheCleanupLoopInterval?: DurationInSec;
53+
cacheTtl?: DurationInSec;
5254
};
5355

5456
type VaaKey = string;
5557

56-
type VaaConfig = {
58+
export type VaaConfig = {
5759
publishTime: number;
5860
vaa: string;
5961
};
@@ -62,7 +64,7 @@ export class VaaCache {
6264
private cache: Map<string, VaaConfig[]>;
6365
private ttl: number;
6466

65-
constructor(ttl: number = 300) {
67+
constructor(ttl: DurationInSec = 300) {
6668
this.cache = new Map();
6769
this.ttl = ttl;
6870
}
@@ -85,6 +87,9 @@ export class VaaCache {
8587
}
8688

8789
find(arr: VaaConfig[], publishTime: number): VaaConfig | undefined {
90+
// If the publishTime is less than the first element we are
91+
// not sure that this VAA is actually the first VAA after that
92+
// time.
8893
if (arr.length === 0 || publishTime < arr[0].publishTime) {
8994
return undefined;
9095
}
@@ -126,6 +131,7 @@ export class Listener implements PriceStore {
126131
private updateCallbacks: ((priceInfo: PriceInfo) => any)[];
127132
private observedVaas: LRUCache<VaaKey, boolean>;
128133
private vaasCache: VaaCache;
134+
private cacheCleanupLoopInterval: DurationInSec;
129135

130136
constructor(config: ListenerConfig, promClient?: PromClient) {
131137
this.promClient = promClient;
@@ -137,7 +143,8 @@ export class Listener implements PriceStore {
137143
max: 10000, // At most 10000 items
138144
ttl: 60 * 1000, // 60 seconds
139145
});
140-
this.vaasCache = new VaaCache();
146+
this.vaasCache = new VaaCache(config.cacheTtl);
147+
this.cacheCleanupLoopInterval = config.cacheCleanupLoopInterval ?? 60;
141148
}
142149

143150
private loadFilters(filtersRaw?: string) {
@@ -170,22 +177,21 @@ export class Listener implements PriceStore {
170177
logger.info("loaded " + this.filters.length + " filters");
171178
}
172179

173-
async runCacheCleanupLoop(interval: number = 60) {
174-
setInterval(this.vaasCache.removeExpiredValues, interval * 1000);
175-
}
176-
177180
async run() {
178181
logger.info(
179182
"pyth_relay starting up, will listen for signed VAAs from " +
180183
this.spyServiceHost
181184
);
182185

186+
setInterval(
187+
this.vaasCache.removeExpiredValues.bind(this.vaasCache),
188+
this.cacheCleanupLoopInterval * 1000
189+
);
190+
183191
while (true) {
184192
let stream: ClientReadableStream<SubscribeSignedVAAResponse> | undefined;
185193
try {
186-
const client = createSpyRPCServiceClient(
187-
process.env.SPY_SERVICE_HOST || ""
188-
);
194+
const client = createSpyRPCServiceClient(this.spyServiceHost);
189195
stream = await subscribeSignedVAA(client, { filters: this.filters });
190196

191197
stream!.on("data", ({ vaaBytes }: { vaaBytes: Buffer }) => {

price-service/src/rest.ts

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { Server } from "http";
66
import { StatusCodes } from "http-status-codes";
77
import morgan from "morgan";
88
import fetch from "node-fetch";
9-
import { TimestampInSec } from "./helpers";
10-
import { PriceStore } from "./listen";
9+
import { removeLeading0x, TimestampInSec } from "./helpers";
10+
import { PriceStore, VaaConfig } from "./listen";
1111
import { logger } from "./logging";
1212
import { PromClient } from "./promClient";
13+
import { retry } from "ts-retry-promise";
1314

1415
const MORGAN_LOG_FORMAT =
1516
':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
@@ -27,9 +28,25 @@ export class RestException extends Error {
2728
static PriceFeedIdNotFound(notFoundIds: string[]): RestException {
2829
return new RestException(
2930
StatusCodes.BAD_REQUEST,
30-
`Price Feeds with ids ${notFoundIds.join(", ")} not found`
31+
`Price Feed(s) with id(s) ${notFoundIds.join(", ")} not found.`
3132
);
3233
}
34+
35+
static DbApiError(): RestException {
36+
return new RestException(StatusCodes.INTERNAL_SERVER_ERROR, `DB API Error`);
37+
}
38+
39+
static VaaNotFound(): RestException {
40+
return new RestException(StatusCodes.NOT_FOUND, "VAA not found.");
41+
}
42+
}
43+
44+
function asyncWrapper(
45+
callback: (req: Request, res: Response, next: NextFunction) => Promise<any>
46+
) {
47+
return function (req: Request, res: Response, next: NextFunction) {
48+
callback(req, res, next).catch(next);
49+
};
3350
}
3451

3552
export class RestAPI {
@@ -54,6 +71,39 @@ export class RestAPI {
5471
this.promClient = promClient;
5572
}
5673

74+
async getVaaWithDbLookup(priceFeedId: string, publishTime: TimestampInSec) {
75+
// Try to fetch the vaa from the local cache
76+
let vaa = this.priceFeedVaaInfo.getVaa(priceFeedId, publishTime);
77+
78+
// if publishTime is older than cache ttl or vaa is not found, fetch from db
79+
if (vaa === undefined && this.dbApiEndpoint && this.dbApiCluster) {
80+
const priceFeedWithoutLeading0x = removeLeading0x(priceFeedId);
81+
82+
try {
83+
const data = (await retry(
84+
() =>
85+
fetch(
86+
`${this.dbApiEndpoint}/vaa?id=${priceFeedWithoutLeading0x}&publishTime=${publishTime}&cluster=${this.dbApiCluster}`
87+
).then((res) => res.json()),
88+
{ retries: 3 }
89+
)) as any[];
90+
if (data.length > 0) {
91+
vaa = {
92+
vaa: data[0].vaa,
93+
publishTime: Math.floor(
94+
new Date(data[0].publishTime).getTime() / 1000
95+
),
96+
};
97+
}
98+
} catch (e: any) {
99+
logger.error(`DB API Error: ${e}`);
100+
throw RestException.DbApiError();
101+
}
102+
}
103+
104+
return vaa;
105+
}
106+
57107
// Run this function without blocking (`await`) if you want to run it async.
58108
async createApp() {
59109
const app = express();
@@ -126,43 +176,92 @@ export class RestAPI {
126176
publish_time: Joi.number().required(),
127177
}).required(),
128178
};
179+
129180
app.get(
130181
"/api/get_vaa",
131182
validate(getVaaInputSchema),
132-
(req: Request, res: Response) => {
183+
asyncWrapper(async (req: Request, res: Response) => {
133184
const priceFeedId = req.query.id as string;
134185
const publishTime = Number(req.query.publish_time as string);
135-
const vaa = this.priceFeedVaaInfo.getVaa(priceFeedId, publishTime);
136-
// if publishTime is older than cache ttl or vaa is not found, fetch from db
137-
if (!vaa) {
138-
// cache miss
139-
if (this.dbApiEndpoint && this.dbApiCluster) {
140-
fetch(
141-
`${this.dbApiEndpoint}/vaa?id=${priceFeedId}&publishTime=${publishTime}&cluster=${this.dbApiCluster}`
142-
)
143-
.then((r: any) => r.json())
144-
.then((arr: any) => {
145-
if (arr.length > 0 && arr[0]) {
146-
res.json(arr[0]);
147-
} else {
148-
res.status(StatusCodes.NOT_FOUND).send("VAA not found");
149-
}
150-
});
151-
}
186+
187+
if (
188+
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
189+
) {
190+
throw RestException.PriceFeedIdNotFound([priceFeedId]);
191+
}
192+
193+
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime);
194+
195+
if (vaa === undefined) {
196+
throw RestException.VaaNotFound();
152197
} else {
153-
// cache hit
154-
const processedVaa = {
155-
publishTime: new Date(vaa.publishTime),
156-
vaa: vaa.vaa,
157-
};
158-
res.json(processedVaa);
198+
res.json(vaa);
159199
}
160-
}
200+
})
161201
);
202+
162203
endpoints.push(
163204
"api/get_vaa?id=<price_feed_id>&publish_time=<publish_time_in_unix_timestamp>"
164205
);
165206

207+
const getVaaCcipInputSchema: schema = {
208+
query: Joi.object({
209+
data: Joi.string()
210+
.regex(/^0x[a-f0-9]{80}$/)
211+
.required(),
212+
}).required(),
213+
};
214+
215+
// CCIP compatible endpoint. Read more information about it from
216+
// https://eips.ethereum.org/EIPS/eip-3668
217+
app.get(
218+
"/api/get_vaa_ccip",
219+
validate(getVaaCcipInputSchema),
220+
asyncWrapper(async (req: Request, res: Response) => {
221+
const dataHex = req.query.data as string;
222+
const data = Buffer.from(removeLeading0x(dataHex), "hex");
223+
224+
const priceFeedId = data.slice(0, 32).toString("hex");
225+
const publishTime = Number(data.readBigInt64BE(32));
226+
227+
if (
228+
this.priceFeedVaaInfo.getLatestPriceInfo(priceFeedId) === undefined
229+
) {
230+
throw RestException.PriceFeedIdNotFound([priceFeedId]);
231+
}
232+
233+
const vaa = await this.getVaaWithDbLookup(priceFeedId, publishTime);
234+
235+
if (vaa === undefined) {
236+
// Returning Bad Gateway error because CCIP expects a 5xx error if it needs to
237+
// retry or try other endpoints. Bad Gateway seems the best choice here as this
238+
// is not an internal error and could happen on two scenarios:
239+
// 1. DB Api is not responding well (Bad Gateway is appropriate here)
240+
// 2. Publish time is a few seconds before current time and a VAA
241+
// Will be available in a few seconds. So we want the client to retry.
242+
res
243+
.status(StatusCodes.BAD_GATEWAY)
244+
.json({ "message:": "VAA not found." });
245+
} else {
246+
const pubTimeBuffer = Buffer.alloc(8);
247+
pubTimeBuffer.writeBigInt64BE(BigInt(vaa.publishTime));
248+
249+
const resData =
250+
"0x" +
251+
pubTimeBuffer.toString("hex") +
252+
Buffer.from(vaa.vaa, "base64").toString("hex");
253+
254+
res.json({
255+
data: resData,
256+
});
257+
}
258+
})
259+
);
260+
261+
endpoints.push(
262+
"api/get_vaa_ccip?data=<0x<price_feed_id_32_bytes>+<publish_time_unix_timestamp_be_8_bytes>>"
263+
);
264+
166265
const latestPriceFeedsInputSchema: schema = {
167266
query: Joi.object({
168267
ids: Joi.array()

0 commit comments

Comments
 (0)