Skip to content

Commit

Permalink
feat: pricing package refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Aug 19, 2024
1 parent 3bd372a commit 1fd0604
Show file tree
Hide file tree
Showing 19 changed files with 525 additions and 4 deletions.
13 changes: 10 additions & 3 deletions packages/pricing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@
"lint": "eslint \"{src,test}/**/*.{js,ts,json}\"",
"lint:fix": "pnpm lint --fix",
"format": "prettier --check \"{src,test}/**/*.{js,ts,json}\"",
"format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\""
"format:fix": "prettier --write \"{src,test}/**/*.{js,ts,json}\"",
"test": "vitest run --config vitest.config.ts --passWithNoTests",
"test:cov": "vitest run --config vitest.config.ts --coverage"
},
"dependencies": {},
"devDependencies": {}
"dependencies": {
"@zkchainhub/shared": "workspace:*",
"axios": "1.7.4"
},
"devDependencies": {
"axios-mock-adapter": "2.0.0"
}
}
5 changes: 5 additions & 0 deletions packages/pricing/src/exceptions/apiNotAvailable.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class ApiNotAvailable extends Error {
constructor(apiName: string) {
super(`The ${apiName} API is not available.`);
}
}
2 changes: 2 additions & 0 deletions packages/pricing/src/exceptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./apiNotAvailable.exception.js";
export * from "./rateLimitExceeded.exception.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class RateLimitExceeded extends Error {
constructor() {
super("Rate limit exceeded.");
}
}
3 changes: 3 additions & 0 deletions packages/pricing/src/external.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { IPricingService, RateLimitExceeded, ApiNotAvailable } from "./internal.js";

export { CoingeckoService } from "./internal.js";
1 change: 1 addition & 0 deletions packages/pricing/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./external.js";
1 change: 1 addition & 0 deletions packages/pricing/src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./pricing.interface.js";
16 changes: 16 additions & 0 deletions packages/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// providers
export type PricingProvider = "coingecko";

/**
* Represents a pricing service that retrieves token prices.
*/
export interface IPricingService {
/**
* Retrieves the prices of the specified tokens.
* @param tokenIds - An array of token IDs.
* @returns A promise that resolves to a record containing the token IDs as keys and their corresponding prices as values.
*/
getTokenPrices<TokenId extends string = string>(
tokenIds: TokenId[],
): Promise<Record<string, number>>;
}
4 changes: 4 additions & 0 deletions packages/pricing/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./types/index.js";
export * from "./interfaces/index.js";
export * from "./exceptions/index.js";
export * from "./services/index.js";
154 changes: 154 additions & 0 deletions packages/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { isNativeError } from "util/types";
import axios, { AxiosInstance, isAxiosError } from "axios";

import { BASE_CURRENCY, Cache, ILogger } from "@zkchainhub/shared";

import type { IPricingService, TokenPrices } from "../internal.js";
import { ApiNotAvailable, RateLimitExceeded } from "../internal.js";

export const AUTH_HEADER = (type: "demo" | "pro") =>
type === "demo" ? "x-cg-demo-api-key" : "x-cg-pro-api-key";
export const DECIMALS_PRECISION = 3;

// interfaces
interface CoingeckoOptions {
apiKey: string;
apiBaseUrl: string;
apiType: "demo" | "pro";
}

/**
* Service for fetching token prices from Coingecko API.
* Prices are always denominated in USD.
*/
export class CoingeckoService implements IPricingService {
private readonly axios: AxiosInstance;

/**
*
* @param
* @param options.apiKey - Coingecko API key.
* @param options.apiBaseUrl - Base URL for Coingecko API. If you have a Pro account, you can use the Pro API URL.
*/
constructor(
private readonly options: CoingeckoOptions,
private readonly cacheManager: Cache,
private readonly logger: ILogger,
) {
const { apiKey, apiBaseUrl, apiType } = options;

this.axios = axios.create({
baseURL: apiBaseUrl,
headers: {
common: {
[AUTH_HEADER(apiType)]: apiKey,
Accept: "application/json",
},
},
});
this.axios.interceptors.response.use(
(response) => response,
(error: unknown) => this.handleError(error),
);
}

/**
* @param tokenIds - An array of Coingecko Tokens IDs.
* @returns A promise that resolves to a record of token prices in USD.
*/
async getTokenPrices(tokenIds: string[]): Promise<Record<string, number>> {
const cachedTokenPrices = await this.getTokenPricesFromCache(tokenIds);
const missingTokenIds: string[] = [];
const cachedMap = cachedTokenPrices.reduce(
(result, price, index) => {
if (price) result[tokenIds.at(index) as string] = price;
else missingTokenIds.push(tokenIds.at(index) as string);

return result;
},
{} as Record<string, number>,
);

const missingTokenPrices = await this.fetchTokenPrices(missingTokenIds);

await this.saveTokenPricesToCache(missingTokenPrices);

return { ...cachedMap, ...missingTokenPrices };
}

/**
* Retrieves multiple token prices from the cache at once.
* @param keys - An array of cache keys.
* @returns A promise that resolves to an array of token prices (number or null).
*/
private async getTokenPricesFromCache(keys: string[]): Promise<(number | null)[]> {
return this.cacheManager.store.mget(...keys) as Promise<(number | null)[]>;
}

/**
* Saves multiple token prices to the cache at once.
*
* @param prices - The token prices to be saved.
* @param currency - The currency in which the prices are denominated.
*/
private async saveTokenPricesToCache(prices: Record<string, number>) {
if (Object.keys(prices).length === 0) return;

this.cacheManager.store.mset(
Object.entries(prices).map(([tokenId, price]) => [tokenId, price]),
);
}

private async fetchTokenPrices(tokenIds: string[]): Promise<Record<string, number>> {
if (tokenIds.length === 0) {
return {};
}

return this.axios
.get<TokenPrices>("simple/price", {
params: {
vs_currencies: BASE_CURRENCY,
ids: tokenIds.join(","),
precision: DECIMALS_PRECISION.toString(),
},
})
.then((response) => {
const { data } = response;
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, value.usd]),
);
});
}

/**
* Handles errors that occur during API requests.
* @param error - The error object to handle.
* @throws {ApiNotAvailable} - If the error is a server-side error (status code >= 500).
* @throws {RateLimitExceeded} - If the error is a rate limit exceeded error (status code 429).
* @throws {Error} - If the error is a client-side error or an unknown error.
* @throws {Error} - If the error is a non-network related error.
*/
private handleError(error: unknown) {
let exception;
if (isAxiosError(error)) {
const statusCode = error.response?.status ?? 0;
if (statusCode >= 500) {
exception = new ApiNotAvailable("Coingecko");
} else if (statusCode === 429) {
exception = new RateLimitExceeded();
} else {
exception = new Error(
error.response?.data || `An error occurred while fetching data: ${error}`,
);
}

throw exception;
} else if (isNativeError(error)) {
this.logger.error(error);
throw new Error("A non network related error occurred");
} else {
this.logger.error(JSON.stringify(error));
throw new Error("A non network related error occurred");
}
}
}
1 change: 1 addition & 0 deletions packages/pricing/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./coingecko.service.js";
1 change: 1 addition & 0 deletions packages/pricing/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./tokenPrice.type.js";
11 changes: 11 additions & 0 deletions packages/pricing/src/types/tokenPrice.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type TokenPrice = {
usd: number;
usd_market_cap?: number;
usd_24h_vol?: number;
usd_24h_change?: number;
last_updated_at?: number;
};

export type TokenPrices = {
[address: string]: TokenPrice;
};
Loading

0 comments on commit 1fd0604

Please sign in to comment.