Skip to content

Commit

Permalink
feat: price in memory caching (#31)
Browse files Browse the repository at this point in the history
# 🤖 Linear

Closes ZKS-120

## Description

Add an InMemory caching layer for token prices:

- Uses NestJS Caching Module
- TTL of 60secs

---------

Co-authored-by: 0xyaco <[email protected]>
  • Loading branch information
0xnigir1 and 0xyaco authored Aug 1, 2024
1 parent bce4cb6 commit 50bb9f7
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 37 deletions.
3 changes: 0 additions & 3 deletions libs/pricing/src/interfaces/pricing.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ export interface IPricingService {
/**
* Retrieves the prices of the specified tokens.
* @param tokenIds - An array of token IDs.
* @param [config] - Optional configuration object.
* @param config.currency - The currency in which the prices should be returned.
* @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[],
config?: { currency: string },
): Promise<Record<string, number>>;
}
10 changes: 9 additions & 1 deletion libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";

import { LoggerModule } from "@zkchainhub/shared";
import { TOKEN_CACHE_TTL_IN_SEC } from "@zkchainhub/shared/constants/";

import { CoingeckoService } from "./services";

@Module({
imports: [LoggerModule],
imports: [
LoggerModule,
CacheModule.register({
store: "memory",
ttl: TOKEN_CACHE_TTL_IN_SEC,
}),
],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
57 changes: 35 additions & 22 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { createMock } from "@golevelup/ts-jest";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { Logger } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AxiosError, AxiosInstance } from "axios";
import { AxiosInstance } from "axios";
import MockAdapter from "axios-mock-adapter";
import { WINSTON_MODULE_PROVIDER } from "nest-winston";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { TokenPrices } from "@zkchainhub/pricing/types/tokenPrice.type";
import { BASE_CURRENCY } from "@zkchainhub/shared";

import { CoingeckoService } from "./coingecko.service";
import { CoingeckoService, DECIMALS_PRECISION } from "./coingecko.service";

export const mockLogger: Partial<Logger> = {
log: jest.fn(),
Expand All @@ -20,6 +23,7 @@ describe("CoingeckoService", () => {
let service: CoingeckoService;
let axios: AxiosInstance;
let mockAxios: MockAdapter;
let cache: Cache;
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";

Expand All @@ -29,21 +33,31 @@ describe("CoingeckoService", () => {
CoingeckoService,
{
provide: CoingeckoService,
useFactory: (logger: Logger) => {
return new CoingeckoService(apiKey, apiBaseUrl, logger);
useFactory: (logger: Logger, cache: Cache) => {
return new CoingeckoService(apiKey, apiBaseUrl, logger, cache);
},
inject: [WINSTON_MODULE_PROVIDER],
inject: [WINSTON_MODULE_PROVIDER, CACHE_MANAGER],
},
{
provide: WINSTON_MODULE_PROVIDER,
useValue: mockLogger,
},
{
provide: CACHE_MANAGER,
useValue: createMock<Cache>({
store: createMock<Cache["store"]>({
mget: jest.fn(),
mset: jest.fn(),
}),
}),
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
axios = service["axios"];
mockAxios = new MockAdapter(axios);
cache = module.get<Cache>(CACHE_MANAGER);
});

afterEach(() => {
Expand All @@ -67,71 +81,70 @@ describe("CoingeckoService", () => {
});

describe("getTokenPrices", () => {
it("return token prices", async () => {
it("fetches all token prices from Coingecko", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";
const expectedResponse: TokenPrices = {
token1: { usd: 1.23 },
token2: { usd: 4.56 },
};

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(axios, "get").mockResolvedValueOnce({
data: expectedResponse,
});

const result = await service.getTokenPrices(tokenIds, { currency });
const result = await service.getTokenPrices(tokenIds);

expect(result).toEqual({
token1: 1.23,
token2: 4.56,
});
expect(axios.get).toHaveBeenCalledWith(`simple/price`, {
params: {
vs_currencies: currency,
vs_currencies: BASE_CURRENCY,
ids: tokenIds.join(","),
precision: service["DECIMALS_PRECISION"].toString(),
precision: DECIMALS_PRECISION.toString(),
},
});
expect(cache.store.mget).toHaveBeenCalledWith("token1", "token2");
expect(cache.store.mset).toHaveBeenCalledWith([
["token1", 1.23],
["token2", 4.56],
]);
});

it("throw ApiNotAvailable when Coingecko returns a 500 family exception", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(503, {
data: {},
status: 503,
statusText: "Service not available",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow(
new ApiNotAvailable("Coingecko"),
);
});

it("throw RateLimitExceeded when Coingecko returns 429 exception", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(429, {
data: {},
status: 429,
statusText: "Too Many Requests",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new RateLimitExceeded(),
);
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow(new RateLimitExceeded());
});

it("throw an HttpException with the error message when an error occurs", async () => {
const tokenIds = ["invalidTokenId", "token2"];
const currency = "usd";

jest.spyOn(axios, "get").mockRejectedValueOnce(
new AxiosError("Invalid token ID", "400"),
);

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
mockAxios.onGet().replyOnce(400, {
data: {
message: "Invalid token ID",
Expand All @@ -140,7 +153,7 @@ describe("CoingeckoService", () => {
statusText: "Bad Request",
});

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow();
await expect(service.getTokenPrices(tokenIds)).rejects.toThrow();
});
});
});
73 changes: 62 additions & 11 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, LoggerService } from "@nestjs/common";
import axios, { AxiosInstance, isAxiosError } from "axios";
import { WINSTON_MODULE_NEST_PROVIDER } from "nest-winston";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
import { IPricingService } from "@zkchainhub/pricing/interfaces";
import { TokenPrices } from "@zkchainhub/pricing/types/tokenPrice.type";
import { BASE_CURRENCY } from "@zkchainhub/shared";

export const AUTH_HEADER = "x-cg-pro-api-key";
export const DECIMALS_PRECISION = 3;

/**
* Service for fetching token prices from Coingecko API.
* Prices are always denominated in USD.
*/
@Injectable()
export class CoingeckoService implements IPricingService {
private readonly AUTH_HEADER = "x-cg-pro-api-key";
private readonly DECIMALS_PRECISION = 3;
private readonly axios: AxiosInstance;

/**
Expand All @@ -24,12 +28,13 @@ export class CoingeckoService implements IPricingService {
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
@Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {
this.axios = axios.create({
baseURL: apiBaseUrl,
headers: {
common: {
[this.AUTH_HEADER]: apiKey,
[AUTH_HEADER]: apiKey,
Accept: "application/json",
},
},
Expand All @@ -42,20 +47,66 @@ export class CoingeckoService implements IPricingService {

/**
* @param tokenIds - An array of Coingecko Tokens IDs.
* @param config.currency - The currency in which the prices should be returned (default: "usd").
* @returns A promise that resolves to a record of token prices in USD.
*/
async getTokenPrices(
tokenIds: string[],
config: { currency: string } = { currency: "usd" },
): Promise<Record<string, number>> {
const { currency } = config;
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 !== null) 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 };
}

private formatTokenCacheKey(tokenId: string, currency: string) {
return `${tokenId}.${currency}`;
}

/**
* 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: currency,
vs_currencies: BASE_CURRENCY,
ids: tokenIds.join(","),
precision: this.DECIMALS_PRECISION.toString(),
precision: DECIMALS_PRECISION.toString(),
},
})
.then((response) => {
Expand Down
Empty file removed libs/shared/src/constants.ts
Empty file.
2 changes: 2 additions & 0 deletions libs/shared/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const TOKEN_CACHE_TTL_IN_SEC = 60;
export const BASE_CURRENCY = "usd";
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"dependencies": {
"@nestjs/axios": "3.0.2",
"@nestjs/cache-manager": "2.2.2",
"@nestjs/common": "10.0.0",
"@nestjs/core": "10.0.0",
"@nestjs/platform-express": "10.0.0",
Expand All @@ -32,6 +33,7 @@
"axios": "1.7.2",
"axios-mock-adapter": "1.22.0",
"nest-winston": "1.9.7",
"cache-manager": "5.7.4",
"reflect-metadata": "0.1.13",
"rxjs": "7.8.1",
"viem": "2.17.5",
Expand Down
Loading

0 comments on commit 50bb9f7

Please sign in to comment.