From bce4cb6208b0403ca2e4e250cc9658a16778ce4a Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:23:30 -0300 Subject: [PATCH 1/2] feat: static token list from top tokens of L1 shared bridge (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes ZKS-134 ## Description Static #50 Token list based on Etherscan from L1 Shared Bridge and data from Coingecko --- libs/shared/src/tokens/tokens.ts | 489 +++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 libs/shared/src/tokens/tokens.ts diff --git a/libs/shared/src/tokens/tokens.ts b/libs/shared/src/tokens/tokens.ts new file mode 100644 index 0000000..ba1ded9 --- /dev/null +++ b/libs/shared/src/tokens/tokens.ts @@ -0,0 +1,489 @@ +export type TokenType = { + name: string; + symbol: string; + coingeckoId: string; + type: "erc20" | "native"; + contractAddress: string | null; + decimals: number; + imageUrl?: string; +}; + +export const tokens: TokenType[] = [ + { + name: "Ethereum", + symbol: "ETH", + contractAddress: null, + coingeckoId: "ethereum", + type: "native", + imageUrl: + "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + decimals: 18, + }, + { + name: "USDC", + symbol: "USDC", + contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + coingeckoId: "usd-coin", + imageUrl: "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694", + type: "erc20", + decimals: 6, + }, + { + name: "Koi", + symbol: "KOI", + contractAddress: "0x9D14BcE1dADdf408d77295BB1be9b343814f44DE", + coingeckoId: "koi-3", + imageUrl: + "https://coin-images.coingecko.com/coins/images/35766/large/Koi_logo.png?1709782399", + type: "erc20", + decimals: 18, + }, + { + name: "Tether USD", + symbol: "USDT", + contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", + coingeckoId: "tether", + imageUrl: "https://coin-images.coingecko.com/coins/images/325/large/Tether.png?1696501661", + type: "erc20", + decimals: 6, + }, + { + name: "Wrapped BTC", + symbol: "WBTC", + contractAddress: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + coingeckoId: "wrapped-bitcoin", + imageUrl: + "https://coin-images.coingecko.com/coins/images/7598/large/wrapped_bitcoin_wbtc.png?1696507857", + type: "erc20", + decimals: 8, + }, + { + name: "HYCO", + symbol: "HYCO", + contractAddress: "0x77F76483399Dc6328456105B1db23e2Aca455bf9", + coingeckoId: "hypercomic", + imageUrl: + "https://coin-images.coingecko.com/coins/images/29407/large/coingecko_HYPERCOMIC_LOGO.png?1696528357", + type: "erc20", + decimals: 18, + }, + { + name: "Rocket Pool ETH", + symbol: "rETH", + contractAddress: "0xae78736Cd615f374D3085123A210448E74Fc6393", + coingeckoId: "rocket-pool-eth", + imageUrl: "https://coin-images.coingecko.com/coins/images/20764/large/reth.png?1696520159", + type: "erc20", + decimals: 18, + }, + { + name: "Idexo Token", + symbol: "IDO", + contractAddress: "0xF9c53268e9de692AE1b2ea5216E24e1c3ad7CB1E", + coingeckoId: "idexo-token", + imageUrl: + "https://coin-images.coingecko.com/coins/images/18523/large/qOiqm7T8_400x400.jpg?1696518004", + type: "erc20", + decimals: 18, + }, + { + name: "Dai Stablecoin", + symbol: "DAI", + contractAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F", + coingeckoId: "dai", + imageUrl: + "https://coin-images.coingecko.com/coins/images/9956/large/Badge_Dai.png?1696509996", + type: "erc20", + decimals: 18, + }, + { + name: "DEXTF Token", + symbol: "DEXTF", + contractAddress: "0x5F64Ab1544D28732F0A24F4713c2C8ec0dA089f0", + coingeckoId: "dextf", + imageUrl: + "https://coin-images.coingecko.com/coins/images/12634/large/0qgT0aMu_400x400.jpg?1696512442", + type: "erc20", + decimals: 18, + }, + { + name: "GOVI", + symbol: "GOVI", + contractAddress: "0xeEAA40B28A2d1b0B08f6f97bB1DD4B75316c6107", + coingeckoId: "govi", + imageUrl: "https://coin-images.coingecko.com/coins/images/13875/large/GOVI.png?1696513619", + type: "erc20", + decimals: 18, + }, + { + name: "LUSD Stablecoin", + symbol: "LUSD", + contractAddress: "0x5f98805A4E8be255a32880FDeC7F6728C6568bA0", + coingeckoId: "liquity-usd", + imageUrl: + "https://coin-images.coingecko.com/coins/images/14666/large/Group_3.png?1696514341", + type: "erc20", + decimals: 18, + }, + { + name: "Pepe", + symbol: "PEPE", + contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933", + coingeckoId: "pepe", + imageUrl: + "https://coin-images.coingecko.com/coins/images/29850/large/pepe-token.jpeg?1696528776", + type: "erc20", + decimals: 18, + }, + { + name: "Symbiosis", + symbol: "SIS", + contractAddress: "0xd38BB40815d2B0c2d2c866e0c72c5728ffC76dd9", + coingeckoId: "symbiosis-finance", + imageUrl: + "https://coin-images.coingecko.com/coins/images/20805/large/SymbiosisFinance_logo-150x150.jpeg?1696520198", + type: "erc20", + decimals: 18, + }, + { + name: "DeversiFi Token", + symbol: "DVF", + contractAddress: "0xDDdddd4301A082e62E84e43F474f044423921918", + coingeckoId: "rhinofi", + imageUrl: + "https://coin-images.coingecko.com/coins/images/16414/large/rhinologo.png?1697736807", + type: "erc20", + decimals: 18, + }, + { + name: "Coinbase Wrapped Staked ETH", + symbol: "cbETH", + contractAddress: "0xBe9895146f7AF43049ca1c1AE358B0541Ea49704", + coingeckoId: "coinbase-wrapped-staked-eth", + imageUrl: "https://coin-images.coingecko.com/coins/images/27008/large/cbeth.png?1709186989", + type: "erc20", + decimals: 18, + }, + { + name: "ZKBase", + symbol: "ZKB", + contractAddress: "0xBBBbbBBB46A1dA0F0C3F64522c275BAA4C332636", + coingeckoId: "zkspace", + imageUrl: + "https://coin-images.coingecko.com/coins/images/13585/large/image_2024-01-16_172847810.png?1705397359", + type: "erc20", + decimals: 18, + }, + { + name: "Wrapped SOL (Wormhole)", + symbol: "SOL", + contractAddress: "0xD31a59c85aE9D8edEFeC411D448f90841571b89c", + coingeckoId: "sol-wormhole", + imageUrl: + "https://coin-images.coingecko.com/coins/images/22876/large/SOL_wh_small.png?1696522175", + type: "erc20", + decimals: 9, + }, + { + name: "Storj", + symbol: "STORJ", + contractAddress: "0xB64ef51C888972c908CFacf59B47C1AfBC0Ab8aC", + coingeckoId: "storj", + imageUrl: "https://coin-images.coingecko.com/coins/images/949/large/storj.png?1696502065", + type: "erc20", + decimals: 18, + }, + { + name: "Wrapped Ether", + symbol: "WETH", + contractAddress: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + coingeckoId: "weth", + imageUrl: "https://coin-images.coingecko.com/coins/images/2518/large/weth.png?1696503332", + type: "erc20", + decimals: 18, + }, + { + name: "Wrapped TON Coin", + symbol: "TONCOIN", + contractAddress: "0x582d872A1B094FC48F5DE31D3B73F2D9bE47def1", + coingeckoId: "the-open-network", + imageUrl: + "https://coin-images.coingecko.com/coins/images/17980/large/ton_symbol.png?1696517498", + type: "erc20", + decimals: 9, + }, + { + name: "LSD Coin", + symbol: "LSD", + contractAddress: "0xfAC77A24E52B463bA9857d6b758ba41aE20e31FF", + coingeckoId: "lsdx-finance", + imageUrl: "https://coin-images.coingecko.com/coins/images/29519/large/logo.png?1696528462", + type: "erc20", + decimals: 18, + }, + { + name: "Curve.Fi USD Stablecoin", + symbol: "crvUSD", + contractAddress: "0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E", + coingeckoId: "crvusd", + imageUrl: + "https://coin-images.coingecko.com/coins/images/30118/large/0xf939e0a03fb07f59a73314e73794be0e57ac1b4e.png?1721097561", + type: "erc20", + decimals: 18, + }, + { + name: "Wrapped liquid staked Ether 2.0", + symbol: "wstETH", + contractAddress: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", + coingeckoId: "wrapped-steth", + imageUrl: + "https://coin-images.coingecko.com/coins/images/18834/large/wstETH.png?1696518295", + type: "erc20", + decimals: 18, + }, + { + name: "SHIBA INU", + symbol: "SHIB", + contractAddress: "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + coingeckoId: "shiba-inu", + imageUrl: "https://coin-images.coingecko.com/coins/images/11939/large/shiba.png?1696511800", + type: "erc20", + decimals: 18, + }, + { + name: "Bella", + symbol: "BEL", + contractAddress: "0xA91ac63D040dEB1b7A5E4d4134aD23eb0ba07e14", + coingeckoId: "bella-protocol", + imageUrl: "https://coin-images.coingecko.com/coins/images/12478/large/Bella.png?1696512296", + type: "erc20", + decimals: 18, + }, + { + name: "1INCH Token", + symbol: "1INCH", + contractAddress: "0x111111111117dC0aa78b770fA6A738034120C302", + coingeckoId: "1inch", + imageUrl: + "https://coin-images.coingecko.com/coins/images/13469/large/1inch-token.png?1696513230", + type: "erc20", + decimals: 18, + }, + { + name: "Deri", + symbol: "DERI", + contractAddress: "0xA487bF43cF3b10dffc97A9A744cbB7036965d3b9", + coingeckoId: "deri-protocol", + imageUrl: + "https://coin-images.coingecko.com/coins/images/13931/large/200vs200.jpg?1696513670", + type: "erc20", + decimals: 18, + }, + { + name: "Worldcoin", + symbol: "WLD", + contractAddress: "0x163f8C2467924be0ae7B5347228CABF260318753", + coingeckoId: "worldcoin-wld", + imageUrl: + "https://coin-images.coingecko.com/coins/images/31069/large/worldcoin.jpeg?1696529903", + type: "erc20", + decimals: 18, + }, + { + name: "Maverick Token", + symbol: "MAV", + contractAddress: "0x7448c7456a97769F6cD04F1E83A4a23cCdC46aBD", + coingeckoId: "maverick-protocol", + imageUrl: + "https://coin-images.coingecko.com/coins/images/30850/large/MAV_Logo.png?1696529701", + type: "erc20", + decimals: 18, + }, + { + name: "Syncus", + symbol: "SYNC", + contractAddress: "0xa41d2f8Ee4F47D3B860A149765A7dF8c3287b7F0", + coingeckoId: "syncus", + imageUrl: + "https://coin-images.coingecko.com/coins/images/33573/large/Syncus.png?1702450708", + type: "erc20", + decimals: 18, + }, + { + name: "ZigZag", + symbol: "ZZ", + contractAddress: "0xC91a71A1fFA3d8B22ba615BA1B9c01b2BBBf55ad", + coingeckoId: "zigzag-2", + imageUrl: "https://coin-images.coingecko.com/coins/images/26141/large/zig_zag.?1696525229", + type: "erc20", + decimals: 18, + }, + { + name: "tBTC v2", + symbol: "tBTC", + contractAddress: "0x18084fbA666a33d37592fA2633fD49a74DD93a88", + coingeckoId: "tbtc", + imageUrl: + "https://coin-images.coingecko.com/coins/images/11224/large/0x18084fba666a33d37592fa2633fd49a74dd93a88.png?1696511155", + type: "erc20", + decimals: 18, + }, + { + name: "rsETH", + symbol: "rsETH", + contractAddress: "0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7", + coingeckoId: "kelp-dao-restaked-eth", + imageUrl: + "https://coin-images.coingecko.com/coins/images/33800/large/Icon___Dark.png?1702991855", + type: "erc20", + decimals: 18, + }, + { + name: "XWG", + symbol: "XWG", + contractAddress: "0x0a77eF9bf662D62Fbf9BA4cf861EaA83F9CC4FEC", + coingeckoId: "x-world-games", + imageUrl: + "https://coin-images.coingecko.com/coins/images/17847/large/200_200_%281%29_%281%29.png?1696790226", + type: "erc20", + decimals: 18, + }, + { + name: "Metaverse Index", + symbol: "MVI", + contractAddress: "0x72e364F2ABdC788b7E918bc238B21f109Cd634D7", + coingeckoId: "metaverse-index", + imageUrl: + "https://coin-images.coingecko.com/coins/images/14684/large/MVI_logo.png?1696514357", + type: "erc20", + decimals: 18, + }, + { + name: "ChainLink Token", + symbol: "LINK", + contractAddress: "0x514910771AF9Ca656af840dff83E8264EcF986CA", + coingeckoId: "chainlink", + imageUrl: + "https://coin-images.coingecko.com/coins/images/877/large/chainlink-new-logo.png?1696502009", + type: "erc20", + decimals: 18, + }, + { + name: "Aave Token", + symbol: "AAVE", + contractAddress: "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + coingeckoId: "aave", + imageUrl: + "https://coin-images.coingecko.com/coins/images/12645/large/aave-token-round.png?1720472354", + type: "erc20", + decimals: 18, + }, + { + name: "Changer", + symbol: "CNG", + contractAddress: "0x5C1d9aA868a30795F92fAe903eDc9eFF269044bf", + coingeckoId: "changer", + imageUrl: "https://coin-images.coingecko.com/coins/images/21786/large/cng.png?1696521140", + type: "erc20", + decimals: 18, + }, + { + name: "Tellor Tributes", + symbol: "TRB", + contractAddress: "0x88dF592F8eb5D7Bd38bFeF7dEb0fBc02cf3778a0", + coingeckoId: "tellor", + imageUrl: + "https://coin-images.coingecko.com/coins/images/9644/large/Blk_icon_current.png?1696509713", + type: "erc20", + decimals: 18, + }, + { + name: "Renzo Restaked ETH", + symbol: "Renzo Restaked ETH", + contractAddress: "0xbf5495Efe5DB9ce00f80364C8B423567e58d2110", + coingeckoId: "renzo-restaked-eth", + imageUrl: + "https://coin-images.coingecko.com/coins/images/34753/large/Ezeth_logo_circle.png?1713496404", + type: "erc20", + decimals: 18, + }, + { + name: "Matic Token", + symbol: "MATIC", + contractAddress: "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", + coingeckoId: "matic-network", + imageUrl: + "https://coin-images.coingecko.com/coins/images/4713/large/polygon.png?1698233745", + type: "erc20", + decimals: 18, + }, + { + name: "WOO", + symbol: "WOO", + contractAddress: "0x4691937a7508860F876c9c0a2a617E7d9E945D4B", + coingeckoId: "woo-network", + imageUrl: + "https://coin-images.coingecko.com/coins/images/12921/large/WOO_Logos_2023_Profile_Pic_WOO.png?1696512709", + type: "erc20", + decimals: 18, + }, + { + name: "Beam", + symbol: "BEAM", + contractAddress: "0x62D0A8458eD7719FDAF978fe5929C6D342B0bFcE", + coingeckoId: "beam-2", + imageUrl: + "https://coin-images.coingecko.com/coins/images/32417/large/chain-logo.png?1698114384", + type: "erc20", + decimals: 18, + }, + { + name: "Fringe", + symbol: "FRIN", + contractAddress: "0xC9fE6E1C76210bE83DC1B5b20ec7FD010B0b1D15", + coingeckoId: "fringe-finance", + imageUrl: "https://coin-images.coingecko.com/coins/images/13222/large/frin.png?1696513001", + type: "erc20", + decimals: 18, + }, + { + name: "Furucombo", + symbol: "COMBO", + contractAddress: "0xfFffFffF2ba8F66D4e51811C5190992176930278", + coingeckoId: "furucombo", + imageUrl: + "https://coin-images.coingecko.com/coins/images/13629/large/COMBO_token_ol.png?1696513377", + type: "erc20", + decimals: 18, + }, + { + name: "mETH", + symbol: "mETH", + contractAddress: "0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa", + coingeckoId: "mantle-staked-ether", + imageUrl: + "https://coin-images.coingecko.com/coins/images/33345/large/symbol_transparent_bg.png?1701697066", + type: "erc20", + decimals: 18, + }, + { + name: "Bonsai3", + symbol: "SEED", + contractAddress: "0xe2353069f71a27bBbe66eEabfF05dE109c7d5E19", + coingeckoId: "bonsai3", + imageUrl: + "https://coin-images.coingecko.com/coins/images/33162/large/logo-bonsai3200x200.png?1700830408", + type: "erc20", + decimals: 18, + }, + { + name: "Rocket Pool", + symbol: "RPL", + contractAddress: "0xD33526068D116cE69F19A9ee46F0bd304F21A51f", + coingeckoId: "rocket-pool", + imageUrl: + "https://coin-images.coingecko.com/coins/images/2090/large/rocket_pool_%28RPL%29.png?1696503058", + type: "erc20", + decimals: 18, + }, +]; From 50bb9f790e6b5d1014947342d518035bc757ea6b Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:23:56 -0300 Subject: [PATCH 2/2] feat: price in memory caching (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 🤖 Linear Closes ZKS-120 ## Description Add an InMemory caching layer for token prices: - Uses NestJS Caching Module - TTL of 60secs --------- Co-authored-by: 0xyaco --- .../src/interfaces/pricing.interface.ts | 3 - libs/pricing/src/pricing.module.ts | 10 ++- .../src/services/coingecko.service.spec.ts | 57 +++++++++------ .../pricing/src/services/coingecko.service.ts | 73 ++++++++++++++++--- libs/shared/src/constants.ts | 0 libs/shared/src/constants/index.ts | 2 + package.json | 2 + pnpm-lock.yaml | 43 +++++++++++ 8 files changed, 153 insertions(+), 37 deletions(-) delete mode 100644 libs/shared/src/constants.ts create mode 100644 libs/shared/src/constants/index.ts diff --git a/libs/pricing/src/interfaces/pricing.interface.ts b/libs/pricing/src/interfaces/pricing.interface.ts index 34055d2..052fc32 100644 --- a/libs/pricing/src/interfaces/pricing.interface.ts +++ b/libs/pricing/src/interfaces/pricing.interface.ts @@ -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( tokenIds: TokenId[], - config?: { currency: string }, ): Promise>; } diff --git a/libs/pricing/src/pricing.module.ts b/libs/pricing/src/pricing.module.ts index 6a5b670..7e1fd53 100644 --- a/libs/pricing/src/pricing.module.ts +++ b/libs/pricing/src/pricing.module.ts @@ -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], }) diff --git a/libs/pricing/src/services/coingecko.service.spec.ts b/libs/pricing/src/services/coingecko.service.spec.ts index 55da593..3321b95 100644 --- a/libs/pricing/src/services/coingecko.service.spec.ts +++ b/libs/pricing/src/services/coingecko.service.spec.ts @@ -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 = { log: jest.fn(), @@ -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/"; @@ -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({ + store: createMock({ + mget: jest.fn(), + mset: jest.fn(), + }), + }), + }, ], }).compile(); service = module.get(CoingeckoService); axios = service["axios"]; mockAxios = new MockAdapter(axios); + cache = module.get(CACHE_MANAGER); }); afterEach(() => { @@ -67,19 +81,19 @@ 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, @@ -87,51 +101,50 @@ describe("CoingeckoService", () => { }); 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", @@ -140,7 +153,7 @@ describe("CoingeckoService", () => { statusText: "Bad Request", }); - await expect(service.getTokenPrices(tokenIds, { currency })).rejects.toThrow(); + await expect(service.getTokenPrices(tokenIds)).rejects.toThrow(); }); }); }); diff --git a/libs/pricing/src/services/coingecko.service.ts b/libs/pricing/src/services/coingecko.service.ts index 567b5bf..f1b64c6 100644 --- a/libs/pricing/src/services/coingecko.service.ts +++ b/libs/pricing/src/services/coingecko.service.ts @@ -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"; @@ -5,14 +6,17 @@ 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; /** @@ -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", }, }, @@ -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> { - const { currency } = config; + async getTokenPrices(tokenIds: string[]): Promise> { + 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, + ); + + 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) { + 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> { + if (tokenIds.length === 0) { + return {}; + } return this.axios .get("simple/price", { params: { - vs_currencies: currency, + vs_currencies: BASE_CURRENCY, ids: tokenIds.join(","), - precision: this.DECIMALS_PRECISION.toString(), + precision: DECIMALS_PRECISION.toString(), }, }) .then((response) => { diff --git a/libs/shared/src/constants.ts b/libs/shared/src/constants.ts deleted file mode 100644 index e69de29..0000000 diff --git a/libs/shared/src/constants/index.ts b/libs/shared/src/constants/index.ts new file mode 100644 index 0000000..bdeb317 --- /dev/null +++ b/libs/shared/src/constants/index.ts @@ -0,0 +1,2 @@ +export const TOKEN_CACHE_TTL_IN_SEC = 60; +export const BASE_CURRENCY = "usd"; diff --git a/package.json b/package.json index c72a7c8..ad48529 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56ceb91..447a081 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@nestjs/axios': specifier: 3.0.2 version: 3.0.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(axios@1.7.2)(rxjs@7.8.1) + '@nestjs/cache-manager': + specifier: 2.2.2 + version: 2.2.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1))(cache-manager@5.7.4)(rxjs@7.8.1) '@nestjs/common': specifier: 10.0.0 version: 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) @@ -32,6 +35,9 @@ importers: axios-mock-adapter: specifier: 1.22.0 version: 1.22.0(axios@1.7.2) + cache-manager: + specifier: 5.7.4 + version: 5.7.4 nest-winston: specifier: 1.9.7 version: 1.9.7(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(winston@3.13.1) @@ -566,6 +572,14 @@ packages: axios: ^1.3.1 rxjs: ^6.0.0 || ^7.0.0 + '@nestjs/cache-manager@2.2.2': + resolution: {integrity: sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + cache-manager: <=5 + rxjs: ^7.0.0 + '@nestjs/cli@10.0.0': resolution: {integrity: sha512-14pju3ejAAUpFe1iK99v/b7Bw96phBMV58GXTSm3TcdgaI4O7UTLXTbMiUNyU+LGr/1CPIfThcWqFyKhDIC9VQ==} engines: {node: '>= 16'} @@ -1234,6 +1248,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cache-manager@5.7.4: + resolution: {integrity: sha512-7B29xK1D8hOVdrP0SAy2DGJ/QZxy2TqxS8s2drlLGYI/xOTSJmXfatks7aKKNHvXN6SnKnPtYCi0T82lslB3Fw==} + engines: {node: '>= 18'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -2377,6 +2395,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -2754,6 +2775,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + promise-coalesce@1.1.2: + resolution: {integrity: sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==} + engines: {node: '>=16'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4065,6 +4090,13 @@ snapshots: axios: 1.7.2 rxjs: 7.8.1 + '@nestjs/cache-manager@2.2.2(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/core@10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1))(cache-manager@5.7.4)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1) + '@nestjs/core': 10.0.0(@nestjs/common@10.0.0(reflect-metadata@0.1.13)(rxjs@7.8.1))(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1) + cache-manager: 5.7.4 + rxjs: 7.8.1 + '@nestjs/cli@10.0.0(@swc/core@1.6.13)': dependencies: '@angular-devkit/core': 16.1.0(chokidar@3.5.3) @@ -4834,6 +4866,13 @@ snapshots: bytes@3.1.2: {} + cache-manager@5.7.4: + dependencies: + eventemitter3: 5.0.1 + lodash.clonedeep: 4.5.0 + lru-cache: 10.4.0 + promise-coalesce: 1.1.2 + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -6205,6 +6244,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.clonedeep@4.5.0: {} + lodash.isplainobject@4.0.6: {} lodash.kebabcase@4.1.1: {} @@ -6524,6 +6565,8 @@ snapshots: process-nextick-args@2.0.1: {} + promise-coalesce@1.1.2: {} + prompts@2.4.2: dependencies: kleur: 3.0.3