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 29, 2024
1 parent 48b22a0 commit 924466f
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 7 deletions.
9 changes: 8 additions & 1 deletion libs/pricing/src/pricing.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { HttpModule } from "@nestjs/axios";
import { CacheModule } from "@nestjs/cache-manager";
import { Module } from "@nestjs/common";

import { CoingeckoService } from "./services";

@Module({
imports: [HttpModule],
imports: [
HttpModule,
CacheModule.register({
store: "memory",
ttl: 60, // seconds
}),
],
providers: [CoingeckoService],
exports: [CoingeckoService],
})
Expand Down
96 changes: 91 additions & 5 deletions libs/pricing/src/services/coingecko.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createMock } from "@golevelup/ts-jest";
import { HttpService } from "@nestjs/axios";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { HttpException, HttpStatus } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AxiosError, AxiosInstance, AxiosResponseHeaders } from "axios";
Expand All @@ -12,6 +13,7 @@ import { CoingeckoService } from "./coingecko.service";
describe("CoingeckoService", () => {
let service: CoingeckoService;
let httpService: HttpService;
let cache: Cache;
const apiKey = "COINGECKO_API_KEY";

beforeEach(async () => {
Expand All @@ -20,39 +22,50 @@ describe("CoingeckoService", () => {
CoingeckoService,
{
provide: CoingeckoService,
useFactory: (httpService: HttpService) => {
useFactory: (httpService: HttpService, cache: Cache) => {
const apiKey = "COINGECKO_API_KEY";
const apiBaseUrl = "https://api.coingecko.com/api/v3/";
return new CoingeckoService(apiKey, apiBaseUrl, httpService);
return new CoingeckoService(apiKey, apiBaseUrl, httpService, cache);
},
inject: [HttpService],
inject: [HttpService, CACHE_MANAGER],
},
{
provide: HttpService,
useValue: createMock<HttpService>({
axiosRef: createMock<AxiosInstance>(),
}),
},
{
provide: CACHE_MANAGER,
useValue: createMock<Cache>({
store: createMock<Cache["store"]>({
mget: jest.fn(),
mset: jest.fn(),
}),
}),
},
],
}).compile();

service = module.get<CoingeckoService>(CoingeckoService);
httpService = module.get<HttpService>(HttpService);
cache = module.get<Cache>(CACHE_MANAGER);
});

it("should be defined", () => {
expect(service).toBeDefined();
});

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(httpService.axiosRef, "get").mockResolvedValueOnce({
data: expectedResponse,
});
Expand All @@ -63,12 +76,77 @@ describe("CoingeckoService", () => {
token1: 1.23,
token2: 4.56,
});
expect(cache.store.mget).toHaveBeenCalledWith("token1.usd", "token2.usd");
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
`${service["apiBaseUrl"]}/simple/price`,
{
params: {
vs_currencies: currency,
ids: "token1,token2",
precision: service["DECIMALS_PRECISION"].toString(),
},
headers: {
"x-cg-pro-api-key": apiKey,
Accept: "application/json",
},
},
);
expect(cache.store.mset).toHaveBeenCalledWith([
["token1.usd", 1.23],
["token2.usd", 4.56],
]);
});

it("all tokens are retrieved from Cache", async () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([1.23, 4.56]);

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

expect(result).toEqual({
token1: 1.23,
token2: 4.56,
});
expect(cache.store.mget).toHaveBeenCalledWith("token1.usd", "token2.usd");
expect(httpService.axiosRef.get).not.toHaveBeenCalled();
expect(cache.store.mset).not.toHaveBeenCalled();
});

it("some tokens are retrieved from cache and some from Coingecko", async () => {
const tokenIds = ["token1", "token2", "token3", "token4"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([1.23, null, 4.56, null]);

jest.spyOn(httpService.axiosRef, "get").mockResolvedValueOnce({
data: {
token2: { usd: 7.89 },
token4: { usd: 10.11 },
},
});

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

expect(result).toEqual({
token1: 1.23,
token2: 7.89,
token3: 4.56,
token4: 10.11,
});
expect(cache.store.mget).toHaveBeenCalledWith(
"token1.usd",
"token2.usd",
"token3.usd",
"token4.usd",
);
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
`${service["apiBaseUrl"]}/simple/price`,
{
params: {
vs_currencies: currency,
ids: tokenIds.join(","),
ids: "token2,token4",
precision: service["DECIMALS_PRECISION"].toString(),
},
headers: {
Expand All @@ -77,12 +155,17 @@ describe("CoingeckoService", () => {
},
},
);
expect(cache.store.mset).toHaveBeenCalledWith([
["token2.usd", 7.89],
["token4.usd", 10.11],
]);
});

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]);
jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Service not available", "503", undefined, null, {
status: 503,
Expand All @@ -102,6 +185,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Rate limit exceeded", "429", undefined, null, {
status: 429,
Expand All @@ -121,6 +205,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["invalidTokenId", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(
new AxiosError("Invalid token ID", "400"),
);
Expand All @@ -132,6 +217,7 @@ describe("CoingeckoService", () => {
const tokenIds = ["token1", "token2"];
const currency = "usd";

jest.spyOn(cache.store, "mget").mockResolvedValueOnce([null, null]);
jest.spyOn(httpService.axiosRef, "get").mockRejectedValueOnce(new Error());

await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(
Expand Down
61 changes: 60 additions & 1 deletion libs/pricing/src/services/coingecko.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { isAxiosError } from "axios";

import { ApiNotAvailable, RateLimitExceeded } from "@zkchainhub/pricing/exceptions";
Expand All @@ -25,6 +26,7 @@ export class CoingeckoService implements IPricingService {
private readonly apiKey: string,
private readonly apiBaseUrl: string = "https://api.coingecko.com/api/v3/",
private readonly httpService: HttpService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}

/**
Expand All @@ -36,6 +38,63 @@ export class CoingeckoService implements IPricingService {
config: { currency: string } = { currency: "usd" },
): 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.httpGet<TokenPrices>("/simple/price", {
vs_currencies: currency,
ids: tokenIds.join(","),
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@
},
"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",
"@nestjs/swagger": "7.4.0",
"abitype": "1.0.5",
"axios": "1.7.2",
"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 924466f

Please sign in to comment.