From 0e3969bcfa744f1d09a6cde24679481dfbc09f40 Mon Sep 17 00:00:00 2001 From: Anxo Rodriguez Date: Mon, 22 Jul 2024 16:34:51 +0100 Subject: [PATCH] Create cow repository for USD estimation based on native price (+tests) (#56) * Create cow API USD estimation * Add missing type * Fix broken tests * Fix repo implementation * Fix service * Implement mocking of native price and basic repository test * Fix test * Handle unsupported tokens * chore: test usd repository for nartive cow price * Rename test to spec * Delete comment * Fix compile issue * Rename throwIfUnsuccessful --- apps/api-e2e/jest.config.ts | 1 + apps/api/jest.config.ts | 1 + apps/notification-producer-e2e/jest.config.ts | 3 +- apps/notification-producer/jest.config.ts | 1 + apps/telegram-e2e/jest.config.ts | 1 + apps/telegram/jest.config.ts | 1 + apps/twap-e2e/jest.config.ts | 1 + apps/twap/jest.config.ts | 1 + jest.config.js | 2 +- jest.setup.ts | 1 + libs/cms-api/jest.config.ts | 1 + libs/notifications/jest.config.ts | 1 + libs/repositories/jest.config.ts | 1 + .../src/UsdRepository/UsdRepository.ts | 25 ++- .../UsdRepositoryCoingecko.test.ts | 7 +- .../UsdRepository/UsdRepositoryCoingecko.ts | 40 ++-- .../UsdRepository/UsdRepositoryCow.spec.ts | 178 ++++++++++++++++++ .../src/UsdRepository/UsdRepositoryCow.ts | 102 ++++++++++ .../UsdRepositoryFallback.spec.ts | 1 - .../UsdRepository/UsdRepositoryFallback.ts | 5 +- libs/repositories/src/const.ts | 30 +++ libs/repositories/src/cowApi.ts | 4 +- .../src/utils/throwIfUnsuccessful.ts | 14 ++ libs/repositories/test/mock.ts | 73 +++++++ libs/services/jest.config.ts | 1 + .../SlippageService/SlippageService.spec.ts | 24 ++- package.json | 2 + yarn.lock | 12 ++ 28 files changed, 500 insertions(+), 34 deletions(-) create mode 100644 jest.setup.ts create mode 100644 libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts create mode 100644 libs/repositories/src/UsdRepository/UsdRepositoryCow.ts create mode 100644 libs/repositories/src/const.ts create mode 100644 libs/repositories/src/utils/throwIfUnsuccessful.ts diff --git a/apps/api-e2e/jest.config.ts b/apps/api-e2e/jest.config.ts index 4e7fd4c..d919baf 100644 --- a/apps/api-e2e/jest.config.ts +++ b/apps/api-e2e/jest.config.ts @@ -16,4 +16,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/api-e2e', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index e414131..819cd42 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/packages/api', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/notification-producer-e2e/jest.config.ts b/apps/notification-producer-e2e/jest.config.ts index 9cb202c..ea3df1f 100644 --- a/apps/notification-producer-e2e/jest.config.ts +++ b/apps/notification-producer-e2e/jest.config.ts @@ -13,5 +13,6 @@ export default { ], }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../..//coverage/notification-producer-e2e', + coverageDirectory: '../../coverage/notification-producer-e2e', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/notification-producer/jest.config.ts b/apps/notification-producer/jest.config.ts index c89eeef..4b6ecb8 100644 --- a/apps/notification-producer/jest.config.ts +++ b/apps/notification-producer/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/notification-producer', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/telegram-e2e/jest.config.ts b/apps/telegram-e2e/jest.config.ts index fa8cee9..8dbe285 100644 --- a/apps/telegram-e2e/jest.config.ts +++ b/apps/telegram-e2e/jest.config.ts @@ -14,4 +14,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../..//coverage/telegram-e2e', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/telegram/jest.config.ts b/apps/telegram/jest.config.ts index f67d8ca..159f5b6 100644 --- a/apps/telegram/jest.config.ts +++ b/apps/telegram/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/telegram', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/twap-e2e/jest.config.ts b/apps/twap-e2e/jest.config.ts index f7ca0c3..45c7d85 100644 --- a/apps/twap-e2e/jest.config.ts +++ b/apps/twap-e2e/jest.config.ts @@ -16,4 +16,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/twap-e2e', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/apps/twap/jest.config.ts b/apps/twap/jest.config.ts index 3c1dd34..3cb0cc4 100644 --- a/apps/twap/jest.config.ts +++ b/apps/twap/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/apps/twap', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/jest.config.js b/jest.config.js index b413e10..3745fc2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,4 +2,4 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', -}; \ No newline at end of file +}; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..d2c9bc6 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1 @@ +import 'reflect-metadata'; diff --git a/libs/cms-api/jest.config.ts b/libs/cms-api/jest.config.ts index ef7b00d..312bb59 100644 --- a/libs/cms-api/jest.config.ts +++ b/libs/cms-api/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/libs/cms-api', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/libs/notifications/jest.config.ts b/libs/notifications/jest.config.ts index a2499f2..94a53fc 100644 --- a/libs/notifications/jest.config.ts +++ b/libs/notifications/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/libs/notifications', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/libs/repositories/jest.config.ts b/libs/repositories/jest.config.ts index 089124a..8ef2053 100644 --- a/libs/repositories/jest.config.ts +++ b/libs/repositories/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/libs/repositories', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/libs/repositories/src/UsdRepository/UsdRepository.ts b/libs/repositories/src/UsdRepository/UsdRepository.ts index a6ebbf9..7b64c69 100644 --- a/libs/repositories/src/UsdRepository/UsdRepository.ts +++ b/libs/repositories/src/UsdRepository/UsdRepository.ts @@ -1,3 +1,5 @@ +import { SupportedChainId } from '../types'; + export const usdRepositorySymbol = Symbol.for('UsdRepository'); export type PriceStrategy = '5m' | 'hourly' | 'daily'; @@ -20,11 +22,30 @@ export interface PricePoint { } export interface UsdRepository { - getUsdPrice(chainId: number, tokenAddress: string): Promise; + getUsdPrice( + chainId: SupportedChainId, + tokenAddress: string + ): Promise; getUsdPrices( - chainId: number, + chainId: SupportedChainId, tokenAddress: string, priceStrategy: PriceStrategy ): Promise; } + +export class UsdRepositoryNoop implements UsdRepository { + getUsdPrice( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + return null; + } + getUsdPrices( + chainId: SupportedChainId, + tokenAddress: string, + priceStrategy: PriceStrategy + ): Promise { + return null; + } +} diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts index c9f44a2..0b432da 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts @@ -7,6 +7,7 @@ import ms from 'ms'; const FIVE_MINUTES = ms('5m'); const ONE_HOUR = ms('1h'); const ONE_DAY = ms('1d'); +const BUFFER_ERROR_TOLERANCE = 1.5; // 50% error tolerance describe('UsdRepositoryCoingecko', () => { let usdRepositoryCoingecko: UsdRepositoryCoingecko; @@ -62,7 +63,7 @@ describe('UsdRepositoryCoingecko', () => { Math.abs( price.date.getTime() - previousPrice.date.getTime() - FIVE_MINUTES ) - ).toBeLessThanOrEqual(FIVE_MINUTES); // 5 min of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 5min apart) + ).toBeLessThanOrEqual(FIVE_MINUTES * BUFFER_ERROR_TOLERANCE); // 5 min of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 5min apart) expect(price.price).toBeGreaterThan(0); } }); @@ -98,7 +99,7 @@ describe('UsdRepositoryCoingecko', () => { Math.abs( price.date.getTime() - previousPrice.date.getTime() - ONE_HOUR ) - ).toBeLessThanOrEqual(ONE_HOUR); // 1 hour of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 1 hour apart) + ).toBeLessThanOrEqual(ONE_HOUR * BUFFER_ERROR_TOLERANCE); // 1 hour of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 1 hour apart) expect(price.price).toBeGreaterThan(0); } }); @@ -123,7 +124,7 @@ describe('UsdRepositoryCoingecko', () => { Math.abs( price.date.getTime() - previousPrice.date.getTime() - ONE_DAY ) - ).toBeLessThanOrEqual(ONE_DAY); // 1 day of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 1 day apart) + ).toBeLessThanOrEqual(ONE_DAY * BUFFER_ERROR_TOLERANCE); // 1 day of error tolerance (we don't need to be super precise, but we also want to assert the points are kind of 1 day apart) expect(price.price).toBeGreaterThan(0); } }); diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.ts index 71d6550..605d33a 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.ts @@ -1,8 +1,8 @@ -import 'reflect-metadata'; - import { injectable } from 'inversify'; import { PricePoint, PriceStrategy, UsdRepository } from './UsdRepository'; import { COINGECKO_PLATFORMS, coingeckoProClient } from '../coingecko'; +import { SupportedChainId } from '../types'; +import { throwIfUnsuccessful } from '../utils/throwIfUnsuccessful'; /** * Number of days of data to fetch for each price strategy @@ -22,7 +22,7 @@ const DAYS_PER_PRICE_STRATEGY: Record = { @injectable() export class UsdRepositoryCoingecko implements UsdRepository { async getUsdPrice( - chainId: number, + chainId: SupportedChainId, tokenAddress: string ): Promise { const platform = COINGECKO_PLATFORMS[chainId]; @@ -32,9 +32,8 @@ export class UsdRepositoryCoingecko implements UsdRepository { const tokenAddressLower = tokenAddress.toLowerCase(); - // Get prices: See https://docs.coingecko.com/reference/contract-address-market-chart - // Get prices. See https://docs.coingecko.com/reference/coins-id-market-chart - const fetchResponse = await coingeckoProClient.GET( + // Get USD price: https://docs.coingecko.com/reference/simple-token-price + const { data: priceData, response } = await coingeckoProClient.GET( `/simple/token_price/{id}`, { params: { @@ -49,20 +48,23 @@ export class UsdRepositoryCoingecko implements UsdRepository { } ); - if (fetchResponse.error) { - throw fetchResponse.error; - } - const priceData = fetchResponse.data; - - if (!priceData[tokenAddressLower] || !priceData[tokenAddressLower].usd) { + if ( + response.status === 404 || + !priceData[tokenAddressLower] || + !priceData[tokenAddressLower].usd + ) { return null; } + await throwIfUnsuccessful( + 'Error getting USD price from Coingecko', + response + ); return priceData[tokenAddressLower].usd; } async getUsdPrices( - chainId: number, + chainId: SupportedChainId, tokenAddress: string, priceStrategy: PriceStrategy ): Promise { @@ -75,7 +77,7 @@ export class UsdRepositoryCoingecko implements UsdRepository { const days = DAYS_PER_PRICE_STRATEGY[priceStrategy].toString(); // Get prices: See https://docs.coingecko.com/reference/contract-address-market-chart - const fetchResponses = await coingeckoProClient.GET( + const { data: priceData, response } = await coingeckoProClient.GET( `/coins/{id}/contract/{contract_address}/market_chart`, { params: { @@ -92,17 +94,21 @@ export class UsdRepositoryCoingecko implements UsdRepository { } ); - if (!fetchResponses.data) { + if (response.status === 404 || !priceData) { return null; } + await throwIfUnsuccessful( + 'Error getting USD prices from Coingecko', + response + ); const volumesMap = - fetchResponses.data.total_volumes?.reduce((acc, [timestamp, volume]) => { + priceData.total_volumes?.reduce((acc, [timestamp, volume]) => { acc.set(timestamp, volume); return acc; }, new Map()) || undefined; - const prices = fetchResponses.data.prices; + const prices = priceData.prices; const pricePoints = prices.map(([timestamp, price]) => ({ date: new Date(timestamp), price, diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts new file mode 100644 index 0000000..ee08da9 --- /dev/null +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts @@ -0,0 +1,178 @@ +import { SupportedChainId } from '../types'; +import { UsdRepositoryCow } from './UsdRepositoryCow'; +import { cowApiClientMainnet } from '../cowApi'; + +import { + DEFINITELY_NOT_A_TOKEN, + WETH, + errorResponse, + okResponse, +} from '../../test/mock'; +import { USDC } from '../const'; + +function getTokenDecimalsMock(tokenAddress: string) { + return tokenAddress === WETH ? 18 : 6; +} + +const NATIVE_PRICE_ENDPOINT = '/api/v1/token/{token}/native_price'; +const WETH_NATIVE_PRICE = 1; // See https://api.cow.fi/mainnet/api/v1/token/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/native_price +const USDC_PRICE = 288778763.042292; // USD price: 3,462.8585200136 (calculated 1e12 / 288778763.042292). See https://api.cow.fi/mainnet/api/v1/token/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/native_price + +const usdRepositoryCow = new UsdRepositoryCow(getTokenDecimalsMock); + +const cowApiMock = jest.spyOn(cowApiClientMainnet, 'GET'); + +describe('UsdRepositoryCow', () => { + describe('getUsdPrice', () => { + it('USD price calculation is correct', async () => { + // Mock native price + cowApiMock.mockImplementation(async (url, params) => { + const token = (params as any).params.path.token || undefined; + switch (token) { + case WETH: + // Return WETH native price + return okResponse({ + data: { price: WETH_NATIVE_PRICE }, + }); + case USDC[SupportedChainId.MAINNET].address: + // Return USDC native price + return okResponse({ + data: { price: USDC_PRICE }, + }); + + default: + throw new Error('Unexpected token: ' + token); + } + }); + + // Get USD price for WETH + let price = await usdRepositoryCow.getUsdPrice( + SupportedChainId.MAINNET, + WETH + ); + + // Assert that the implementation did the right calls to the API + expect(cowApiMock).toHaveBeenCalledTimes(2); + expect(cowApiMock.mock.calls).toEqual([ + [NATIVE_PRICE_ENDPOINT, { params: { path: { token: WETH } } }], + [ + NATIVE_PRICE_ENDPOINT, + { + params: { path: { token: USDC[SupportedChainId.MAINNET].address } }, + }, + ], + ]); + + // USD calculation based on native price is correct + expect(price).toEqual(3_462.8585200136367); + }); + it('Handles UnsupportedToken(400) errors', async () => { + // Mock native price + const cowApiGet = jest.spyOn(cowApiClientMainnet, 'GET'); + cowApiGet.mockReturnValue( + errorResponse({ + status: 400, + statusText: 'Bad Request', + error: { + errorType: 'UnsupportedToken', + description: 'Token not supported', + }, + }) + ); + + // Get USD price for a not supported token + let price = await usdRepositoryCow.getUsdPrice( + SupportedChainId.MAINNET, + DEFINITELY_NOT_A_TOKEN // See https://api.cow.fi/mainnet/api/v1/token/0x0000000000000000000000000000000000000000/native_price + ); + + // USD calculation based on native price is correct + expect(price).toEqual(null); + }); + + it('Handles NewErrorTypeWeDontHandleYet(400) errors', async () => { + // Mock native price + const cowApiGet = jest.spyOn(cowApiClientMainnet, 'GET'); + cowApiGet.mockReturnValue( + errorResponse({ + status: 400, + statusText: 'Bad Request', + error: { + errorType: 'NewErrorTypeWeDontHandleYet', + description: + "This is a new error type we don't, so we expect the repository to throw", + }, + }) + ); + + // Get USD price for a not supported token + let pricePromise = usdRepositoryCow.getUsdPrice( + SupportedChainId.MAINNET, + WETH + ); + + // USD calculation based on native price is correct + expect(pricePromise).rejects.toThrow( + "Error getting native prices. 400 (Bad Request): Mock response text. NewErrorTypeWeDontHandleYet: This is a new error type we don't, so we expect the repository to throw URL: http://mocked-url.mock" + ); + }); + + it('Handles NotFound(404) errors', async () => { + // Mock native price + const cowApiGet = jest.spyOn(cowApiClientMainnet, 'GET'); + cowApiGet.mockReturnValue( + errorResponse({ + status: 404, + statusText: 'Not Found', + error: undefined, + }) + ); + + // Get USD price for something is not even an address + let price = await usdRepositoryCow.getUsdPrice( + SupportedChainId.MAINNET, + 'this-is-not-a-token' // See https://api.cow.fi/mainnet/api/v1/token/this-is-not-a-token/native_price + ); + + // USD calculation based on native price is correct + expect(price).toEqual(null); + }); + + it('Handles un-expected errors (I_AM_A_TEA_POT)', async () => { + // Mock native price + const cowApiGet = jest.spyOn(cowApiClientMainnet, 'GET'); + cowApiGet.mockReturnValue( + errorResponse({ + status: 418, + statusText: "I'm a teapot", + url: 'http://calling-a-teapot.com', + text: async () => + 'This server is a teapot, and it cannot brew coffee', + error: undefined, + }) + ); + + // Get USD price for something is not even an address + let priceResult = usdRepositoryCow.getUsdPrice( + SupportedChainId.MAINNET, + 'this-is-not-a-token' + ); + + // USD calculation based on native price is correct + expect(priceResult).rejects.toThrow( + "Error getting native prices. 418 (I'm a teapot): This server is a teapot, and it cannot brew coffee. URL: http://calling-a-teapot.com" + ); + }); + }); + + describe('getUsdPrices', () => { + it('Returns null', async () => { + const price = await usdRepositoryCow.getUsdPrices( + SupportedChainId.MAINNET, + WETH, + '5m' + ); + expect(price).toEqual(null); + }); + }); +}); diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts new file mode 100644 index 0000000..78c675f --- /dev/null +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts @@ -0,0 +1,102 @@ +import { injectable } from 'inversify'; +import { UsdRepositoryNoop } from './UsdRepository'; +import { cowApiClientMainnet } from '../cowApi'; +import { OneBigNumber, TenBigNumber, USDC, ZeroBigNumber } from '../const'; +import { SupportedChainId } from '../types'; +import { BigNumber } from 'bignumber.js'; +import { throwIfUnsuccessful } from '../utils/throwIfUnsuccessful'; + +@injectable() +export class UsdRepositoryCow extends UsdRepositoryNoop { + constructor( + private getTokenDecimals: (tokenAddress: string) => number | null + ) { + super(); + } + + async getUsdPrice( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + // Get native price for token (in ETH/xDAI) + const tokenNativePrice = await this.getNativePrice(chainId, tokenAddress); + if (!tokenNativePrice) { + return null; + } + const tokenDecimals = this.getTokenDecimals(tokenAddress); + + // Get native price for USDC (in ETH/xDAI) + const { address: usdAddress, decimals: usdDecimals } = USDC[chainId]; + const usdcNativePrice = await this.getNativePrice(chainId, usdAddress); + if (!usdcNativePrice) { + return null; + } + + const usdcPrice = invertNativeToTokenPrice( + new BigNumber(usdcNativePrice), + usdDecimals + ); + const tokenPrice = invertNativeToTokenPrice( + new BigNumber(tokenNativePrice), + tokenDecimals + ); + + if (tokenPrice.eq(ZeroBigNumber)) { + return null; + } + + return usdcPrice.div(tokenPrice).toNumber(); + } + + private async getNativePrice( + _chainId: SupportedChainId, + tokenAddress: string + ) { + const { + data: priceResult = {}, + response, + error, + } = await cowApiClientMainnet.GET('/api/v1/token/{token}/native_price', { + params: { + path: { + token: tokenAddress, + }, + }, + }); + + // If tokens is not found, return null. See See https://api.cow.fi/mainnet/api/v1/token/this-is-not-a-token/native_price + if (response.status === 404) { + return null; + } + + // Unsupported tokens return undefined. See https://api.cow.fi/mainnet/api/v1/token/0x0000000000000000000000000000000000000000/native_price + if (response.status === 400) { + const errorType = (error as any)?.errorType; + const description = (error as any)?.description; + if (errorType === 'UnsupportedToken') { + return null; + } else { + await throwIfUnsuccessful( + `Error getting native prices`, + response, + errorType && description ? `${errorType}: ${description}` : undefined + ); + } + } + + await throwIfUnsuccessful('Error getting native prices', response); + + return priceResult.price || null; + } +} + +/** + * API response value represents the amount of native token atoms needed to buy 1 atom of the specified token + * This function inverts the price to represent the amount of specified token atoms needed to buy 1 atom of the native token + */ +function invertNativeToTokenPrice( + value: BigNumber, + decimals: number +): BigNumber { + return OneBigNumber.times(TenBigNumber.pow(18 - decimals)).div(value); +} diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryFallback.spec.ts b/libs/repositories/src/UsdRepository/UsdRepositoryFallback.spec.ts index 243899d..7c9ad33 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryFallback.spec.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryFallback.spec.ts @@ -2,7 +2,6 @@ import { SupportedChainId } from '../types'; import { PricePoint, UsdRepository } from './UsdRepository'; import { UsdRepositoryFallback } from './UsdRepositoryFallback'; import { WETH } from '../../test/mock'; - const mockDate = new Date('2024-01-01T00:00:00Z'); class UsdRepositoryMock_1_1 implements UsdRepository { async getUsdPrice() { diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryFallback.ts b/libs/repositories/src/UsdRepository/UsdRepositoryFallback.ts index 95fb7b3..390ea26 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryFallback.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryFallback.ts @@ -1,12 +1,13 @@ import { injectable } from 'inversify'; import { PricePoint, PriceStrategy, UsdRepository } from './UsdRepository'; +import { SupportedChainId } from '../types'; @injectable() export class UsdRepositoryFallback implements UsdRepository { constructor(private usdRepositories: UsdRepository[]) {} async getUsdPrice( - chainId: number, + chainId: SupportedChainId, tokenAddress: string ): Promise { for (const usdRepository of this.usdRepositories) { @@ -19,7 +20,7 @@ export class UsdRepositoryFallback implements UsdRepository { } async getUsdPrices( - chainId: number, + chainId: SupportedChainId, tokenAddress: string, priceStrategy: PriceStrategy ): Promise { diff --git a/libs/repositories/src/const.ts b/libs/repositories/src/const.ts new file mode 100644 index 0000000..9cb9888 --- /dev/null +++ b/libs/repositories/src/const.ts @@ -0,0 +1,30 @@ +import BigNumber from 'bignumber.js'; +import { SupportedChainId } from './types'; + +interface TokenAddressAndDecimals { + address: string; + decimals: number; +} + +export const USDC: Record = { + [SupportedChainId.MAINNET]: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + [SupportedChainId.GNOSIS_CHAIN]: { + address: '0x2a22f9c3b484c3629090feed35f17ff8f88f76f0', + decimals: 6, + }, + [SupportedChainId.ARBITRUM_ONE]: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + decimals: 6, + }, + [SupportedChainId.SEPOLIA]: { + address: '0xbe72E441BF55620febc26715db68d3494213D8Cb', + decimals: 18, + }, +}; + +export const ZeroBigNumber = new BigNumber(0); +export const OneBigNumber = new BigNumber(1); +export const TenBigNumber = new BigNumber(10); diff --git a/libs/repositories/src/cowApi.ts b/libs/repositories/src/cowApi.ts index 5511599..3795668 100644 --- a/libs/repositories/src/cowApi.ts +++ b/libs/repositories/src/cowApi.ts @@ -4,6 +4,6 @@ const COW_API_BASE_URL = process.env.COW_API_BASE_URL || 'https://api.cow.fi'; import type { paths } from './gen/cow/cow-api-types'; -export const cowApiClient = createClient({ - baseUrl: COW_API_BASE_URL, +export const cowApiClientMainnet = createClient({ + baseUrl: COW_API_BASE_URL + '/mainnet', }); diff --git a/libs/repositories/src/utils/throwIfUnsuccessful.ts b/libs/repositories/src/utils/throwIfUnsuccessful.ts new file mode 100644 index 0000000..9ca217a --- /dev/null +++ b/libs/repositories/src/utils/throwIfUnsuccessful.ts @@ -0,0 +1,14 @@ +export async function throwIfUnsuccessful( + errorMessage: string, + response: Response, + context?: string +) { + if (!response.ok || response.status !== 200) { + const text = await response.text().catch(() => undefined); + throw new Error( + `${errorMessage}. ${response.status} (${response.statusText})${ + text ? ': ' + text : '' + }. ${context ? context + ' ' : ''}URL: ${response.url}` + ); + } +} diff --git a/libs/repositories/test/mock.ts b/libs/repositories/test/mock.ts index f89d334..06ae1cf 100644 --- a/libs/repositories/test/mock.ts +++ b/libs/repositories/test/mock.ts @@ -1,3 +1,76 @@ +import { FetchResponse } from 'openapi-fetch'; + export const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; export const DEFINITELY_NOT_A_TOKEN = '0x0000000000000000000000000000000000000000'; + +const MOCK_RESPONSE: Response = { + status: 200, + statusText: 'OK', + ok: true, + headers: undefined, + redirected: false, + type: 'basic', + url: 'http://mocked-url.mock', + body: undefined, + bodyUsed: false, + async text() { + return 'Mock response text'; + }, + arrayBuffer(): Promise { + throw new Error('Function not implemented.'); + }, + clone(): Response { + throw new Error('Function not implemented.'); + }, + blob(): Promise { + throw new Error('Function not implemented.'); + }, + formData(): Promise { + throw new Error('Function not implemented.'); + }, + json(): Promise { + throw new Error('Function not implemented.'); + }, +}; + +interface OkResponseParams extends Partial> { + data: unknown; +} + +export function okResponse(params: OkResponseParams): { + data: unknown; + response: Response; +} { + const { status, data, ...overrides } = params; + return { + response: { + ...MOCK_RESPONSE, + ...overrides, + status: 200, + ok: true, + }, + data, + }; +} + +interface ErrorResponseParams extends Partial> { + status: number; + error: unknown; +} + +export function errorResponse(params: ErrorResponseParams): { + response: Response; + error?: unknown; +} { + const { status, error, ...overrides } = params; + return { + response: { + ...MOCK_RESPONSE, + ...overrides, + status, + ok: false, + }, + error, + }; +} diff --git a/libs/services/jest.config.ts b/libs/services/jest.config.ts index 972e461..6f179e2 100644 --- a/libs/services/jest.config.ts +++ b/libs/services/jest.config.ts @@ -8,4 +8,5 @@ export default { }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../../coverage/libs/services', + setupFilesAfterEnv: ['../../jest.setup.ts'], }; diff --git a/libs/services/src/SlippageService/SlippageService.spec.ts b/libs/services/src/SlippageService/SlippageService.spec.ts index 9df857e..2f5bddc 100644 --- a/libs/services/src/SlippageService/SlippageService.spec.ts +++ b/libs/services/src/SlippageService/SlippageService.spec.ts @@ -1,17 +1,31 @@ -import 'reflect-metadata'; - import { Container, injectable } from 'inversify'; import { SlippageService, SlippageServiceImpl, slippageServiceSymbol, } from './SlippageService'; -import { UsdRepository, usdRepositorySymbol } from '@cowprotocol/repositories'; +import { + PricePoint, + PriceStrategy, + SupportedChainId, + UsdRepository, + usdRepositorySymbol, +} from '@cowprotocol/repositories'; @injectable() class UsdRepositoryMock implements UsdRepository { - async getDailyUsdPrice(_tokenAddress: string, _date: Date): Promise { - return 1234; + getUsdPrice( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + throw null; + } + getUsdPrices( + chainId: SupportedChainId, + tokenAddress: string, + priceStrategy: PriceStrategy + ): Promise { + throw null; } } diff --git a/package.json b/package.json index ae653a0..5d2005e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "abstract-cache-redis": "^2.0.0", "amqplib": "^0.10.4", "axios": "^1.0.0", + "bignumber.js": "^9.1.2", "ethers": "^5.7.2", "fastify": "~4.13.0", "fastify-caching": "^6.3.0", @@ -67,6 +68,7 @@ "@nx/workspace": "16.3.2", "@typechain/ethers-v5": "^11.0.0", "@types/amqplib": "^0.10.5", + "@types/bignumber.js": "^5.0.0", "@types/jest": "^29.5.0", "@types/mustache": "^4.2.5", "@types/node": "^18.19.31", diff --git a/yarn.lock b/yarn.lock index 7567523..df991a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2887,6 +2887,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/bignumber.js@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/bignumber.js/-/bignumber.js-5.0.0.tgz#d9f1a378509f3010a3255e9cc822ad0eeb4ab969" + integrity sha512-0DH7aPGCClywOFaxxjE6UwpN2kQYe9LwuDQMv+zYA97j5GkOMo8e66LYT+a8JYU7jfmUFRZLa9KycxHDsKXJCA== + dependencies: + bignumber.js "*" + "@types/caseless@*": version "0.12.5" resolved "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz" @@ -3626,6 +3633,11 @@ big.js@^5.2.2: resolved "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bignumber.js@*, bignumber.js@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" + integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"