256 lines
7.3 KiB
TypeScript
256 lines
7.3 KiB
TypeScript
import cors from "cors";
|
|
import express, { NextFunction, Request, Response } from "express";
|
|
import { Joi, schema, validate, ValidationError } from "express-validation";
|
|
import { Server } from "http";
|
|
import { StatusCodes } from "http-status-codes";
|
|
import morgan from "morgan";
|
|
import responseTime from "response-time";
|
|
import { DurationInMs, DurationInSec, TimestampInSec } from "./helpers";
|
|
import { PriceStore } from "./listen";
|
|
import { logger } from "./logging";
|
|
import { PromClient } from "./promClient";
|
|
import { HexString } from "@pythnetwork/pyth-sdk-js";
|
|
|
|
const MORGAN_LOG_FORMAT =
|
|
':remote-addr - :remote-user ":method :url HTTP/:http-version"' +
|
|
' :status :res[content-length] :response-time ms ":referrer" ":user-agent"';
|
|
|
|
export class RestException extends Error {
|
|
statusCode: number;
|
|
message: string;
|
|
constructor(statusCode: number, message: string) {
|
|
super(message);
|
|
this.statusCode = statusCode;
|
|
this.message = message;
|
|
}
|
|
|
|
static PriceFeedIdNotFound(notFoundIds: string[]): RestException {
|
|
return new RestException(
|
|
StatusCodes.BAD_REQUEST,
|
|
`Price Feeds with ids ${notFoundIds.join(", ")} not found`
|
|
);
|
|
}
|
|
}
|
|
|
|
export class RestAPI {
|
|
private port: number;
|
|
private priceFeedVaaInfo: PriceStore;
|
|
private isReady: (() => boolean) | undefined;
|
|
private promClient: PromClient | undefined;
|
|
|
|
constructor(
|
|
config: { port: number },
|
|
priceFeedVaaInfo: PriceStore,
|
|
isReady?: () => boolean,
|
|
promClient?: PromClient
|
|
) {
|
|
this.port = config.port;
|
|
this.priceFeedVaaInfo = priceFeedVaaInfo;
|
|
this.isReady = isReady;
|
|
this.promClient = promClient;
|
|
}
|
|
|
|
// Run this function without blocking (`await`) if you want to run it async.
|
|
async createApp() {
|
|
const app = express();
|
|
app.use(cors());
|
|
|
|
const winstonStream = {
|
|
write: (text: string) => {
|
|
logger.info(text);
|
|
},
|
|
};
|
|
|
|
app.use(morgan(MORGAN_LOG_FORMAT, { stream: winstonStream }));
|
|
|
|
const endpoints: string[] = [];
|
|
|
|
const latestVaasInputSchema: schema = {
|
|
query: Joi.object({
|
|
ids: Joi.array()
|
|
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
|
|
.required(),
|
|
}).required(),
|
|
};
|
|
app.get(
|
|
"/api/latest_vaas",
|
|
validate(latestVaasInputSchema),
|
|
(req: Request, res: Response) => {
|
|
const priceIds = req.query.ids as string[];
|
|
|
|
// 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.
|
|
const vaaMap = new Map<number, Buffer>();
|
|
|
|
const notFoundIds: string[] = [];
|
|
|
|
for (let id of priceIds) {
|
|
if (id.startsWith("0x")) {
|
|
id = id.substring(2);
|
|
}
|
|
|
|
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
|
|
|
|
if (latestPriceInfo === undefined) {
|
|
notFoundIds.push(id);
|
|
continue;
|
|
}
|
|
|
|
vaaMap.set(latestPriceInfo.seqNum, latestPriceInfo.vaa);
|
|
}
|
|
|
|
if (notFoundIds.length > 0) {
|
|
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
|
}
|
|
|
|
const jsonResponse = Array.from(vaaMap.values(), (vaa) =>
|
|
vaa.toString("base64")
|
|
);
|
|
|
|
res.json(jsonResponse);
|
|
}
|
|
);
|
|
endpoints.push(
|
|
"api/latest_vaas?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
|
|
);
|
|
|
|
const latestPriceFeedsInputSchema: schema = {
|
|
query: Joi.object({
|
|
ids: Joi.array()
|
|
.items(Joi.string().regex(/^(0x)?[a-f0-9]{64}$/))
|
|
.required(),
|
|
verbose: Joi.boolean(),
|
|
}).required(),
|
|
};
|
|
app.get(
|
|
"/api/latest_price_feeds",
|
|
validate(latestPriceFeedsInputSchema),
|
|
(req: Request, res: Response) => {
|
|
const priceIds = req.query.ids as string[];
|
|
// verbose is optional, default to false
|
|
const verbose = req.query.verbose === "true";
|
|
|
|
const responseJson = [];
|
|
|
|
const notFoundIds: string[] = [];
|
|
|
|
for (let id of priceIds) {
|
|
if (id.startsWith("0x")) {
|
|
id = id.substring(2);
|
|
}
|
|
|
|
const latestPriceInfo = this.priceFeedVaaInfo.getLatestPriceInfo(id);
|
|
|
|
if (latestPriceInfo === undefined) {
|
|
notFoundIds.push(id);
|
|
continue;
|
|
}
|
|
|
|
if (verbose) {
|
|
responseJson.push({
|
|
...latestPriceInfo.priceFeed.toJson(),
|
|
metadata: {
|
|
emitter_chain: latestPriceInfo.emitterChainId,
|
|
attestation_time: latestPriceInfo.attestationTime,
|
|
sequence_number: latestPriceInfo.seqNum,
|
|
price_service_receive_time:
|
|
latestPriceInfo.priceServiceReceiveTime,
|
|
},
|
|
});
|
|
} else {
|
|
responseJson.push(latestPriceInfo.priceFeed.toJson());
|
|
}
|
|
}
|
|
|
|
if (notFoundIds.length > 0) {
|
|
throw RestException.PriceFeedIdNotFound(notFoundIds);
|
|
}
|
|
|
|
res.json(responseJson);
|
|
}
|
|
);
|
|
endpoints.push(
|
|
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&.."
|
|
);
|
|
endpoints.push(
|
|
"api/latest_price_feeds?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..&verbose=true"
|
|
);
|
|
|
|
app.get("/api/price_feed_ids", (req: Request, res: Response) => {
|
|
const availableIds = this.priceFeedVaaInfo.getPriceIds();
|
|
res.json([...availableIds]);
|
|
});
|
|
endpoints.push("api/price_feed_ids");
|
|
|
|
const staleFeedsInputSchema: schema = {
|
|
query: Joi.object({
|
|
threshold: Joi.number().required(),
|
|
}).required(),
|
|
};
|
|
app.get(
|
|
"/api/stale_feeds",
|
|
validate(staleFeedsInputSchema),
|
|
(req: Request, res: Response) => {
|
|
const stalenessThresholdSeconds = Number(req.query.threshold as string);
|
|
|
|
const currentTime: TimestampInSec = Math.floor(Date.now() / 1000);
|
|
|
|
const priceIds = [...this.priceFeedVaaInfo.getPriceIds()];
|
|
const stalePrices: Record<HexString, number> = {};
|
|
|
|
for (const priceId of priceIds) {
|
|
const latency =
|
|
currentTime -
|
|
this.priceFeedVaaInfo.getLatestPriceInfo(priceId)!.attestationTime;
|
|
if (latency > stalenessThresholdSeconds) {
|
|
stalePrices[priceId] = latency;
|
|
}
|
|
}
|
|
|
|
res.json(stalePrices);
|
|
}
|
|
);
|
|
endpoints.push("/api/stale_feeds?threshold=<staleness_threshold_seconds>");
|
|
|
|
app.get("/ready", (_, res: Response) => {
|
|
if (this.isReady!()) {
|
|
res.sendStatus(StatusCodes.OK);
|
|
} else {
|
|
res.sendStatus(StatusCodes.SERVICE_UNAVAILABLE);
|
|
}
|
|
});
|
|
endpoints.push("ready");
|
|
|
|
app.get("/live", (_, res: Response) => {
|
|
res.sendStatus(StatusCodes.OK);
|
|
});
|
|
endpoints.push("live");
|
|
|
|
// Websocket endpoint
|
|
endpoints.push("ws");
|
|
|
|
app.get("/", (_, res: Response) => res.json(endpoints));
|
|
|
|
app.use((err: any, _: Request, res: Response, next: NextFunction) => {
|
|
if (err instanceof ValidationError) {
|
|
return res.status(err.statusCode).json(err);
|
|
}
|
|
|
|
if (err instanceof RestException) {
|
|
return res.status(err.statusCode).json(err);
|
|
}
|
|
|
|
return next(err);
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
async run(): Promise<Server> {
|
|
const app = await this.createApp();
|
|
return app.listen(this.port, () =>
|
|
logger.debug("listening on REST port " + this.port)
|
|
);
|
|
}
|
|
}
|