Skip to content

Commit

Permalink
feat: add in-memory-cache for token prices
Browse files Browse the repository at this point in the history
  • Loading branch information
0xnigir1 committed Jul 30, 2024
1 parent ee9118a commit 0dd3f0a
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 5 deletions.
9 changes: 8 additions & 1 deletion libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";

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

import { CoingeckoService } from "./services";

@Module({
imports: [LoggerModule],
imports: [
LoggerModule,
CacheModule.register({
store: "memory",
ttl: 60, // seconds
}),
],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
30 changes: 26 additions & 4 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
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";
Expand All @@ -20,6 +22,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 +32,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,14 +80,15 @@ describe("CoingeckoService", () => {
});

describe("getTokenPrices", () => {
it("return token prices", async () => {
it("all token prices are fetched 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,
});
Expand All @@ -92,6 +106,11 @@ describe("CoingeckoService", () => {
precision: service["DECIMALS_PRECISION"].toString(),
},
});
expect(cache.store.mget).toHaveBeenCalledWith("token1.usd", "token2.usd");
expect(cache.store.mset).toHaveBeenCalledWith([
["token1.usd", 1.23],
["token2.usd", 4.56],
]);
});

it("throw ApiNotAvailable when Coingecko returns a 500 family exception", async () => {
Expand All @@ -103,6 +122,7 @@ describe("CoingeckoService", () => {
status: 503,
statusText: "Service not available",
});
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new ApiNotAvailable("Coingecko"),
Expand All @@ -118,6 +138,7 @@ describe("CoingeckoService", () => {
status: 429,
statusText: "Too Many Requests",
});
jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
new RateLimitExceeded(),
Expand All @@ -128,6 +149,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["invalidTokenId", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(axios, "get").mockRejectedValueOnce(
new AxiosError("Invalid token ID", "400"),
);
Expand Down
58 changes: 58 additions & 0 deletions libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
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";
Expand All @@ -24,6 +25,7 @@ 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,
Expand All @@ -50,6 +52,62 @@ export class CoingeckoService implements IPricingService {
): Promise<Record<string, number>> {
const { currency } = config;

const cacheKeys = tokenIds.map((tokenId) => this.formatTokenCacheKey(tokenId, currency));
const cachedTokenPrices = await this.getTokenPricesFromCache(cacheKeys);
const cachedMap = cachedTokenPrices.reduce(
(result, price, index) => {
if (price !== null) result[tokenIds.at(index) as string] = price;
return result;
},
{} as Record<string, number>,
);

const missingTokenIds = tokenIds.filter((_, index) => !cachedTokenPrices[index]);
const missingTokenPrices = await this.fetchTokenPrices(missingTokenIds, currency);

await this.saveTokenPricesToCache(missingTokenPrices, currency);

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>, currency: string) {
if (Object.keys(prices).length === 0) return;

this.cacheManager.store.mset(
Object.entries(prices).map(([key, value]) => [
this.formatTokenCacheKey(key, currency),
value,
]),
);
}

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

return this.axios
.get<TokenPrices>("simple/price", {
params: {
Expand Down
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
43 changes: 43 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0dd3f0a

Please sign in to comment.