From ffab2bbbb29ae334330067d393753055afb12c5a Mon Sep 17 00:00:00 2001 From: Anxo Rodriguez Date: Tue, 30 Jul 2024 16:59:02 +0100 Subject: [PATCH] web3 integrations + get token info + 24h caching time + unit testings (#73) * Add repo and cached repo for erc20 * Add dependency injection of erc20 repository * Add tests for erc20 cache * Add unit tests for viem * Add JS doc to the new repo * Fix name of test * Add all chains automatically --- .env.example | 6 + apps/api/src/app/inversify.config.ts | 39 +++- apps/api/src/app/schemas.ts | 7 +- .../src/Erc20Repository/Erc20Repository.ts | 19 ++ .../Erc20RepositoryCache.spec.ts | 133 ++++++++++++++ .../Erc20Repository/Erc20RepositoryCache.ts | 43 +++++ .../Erc20RepositoryViem.spec.ts | 167 ++++++++++++++++++ .../Erc20Repository/Erc20RepositoryViem.ts | 65 +++++++ .../src/UsdRepository/UsdRepositoryCache.ts | 2 +- .../UsdRepository/UsdRepositoryCow.spec.ts | 16 +- .../src/UsdRepository/UsdRepositoryCow.ts | 8 +- libs/repositories/src/datasources/viem.ts | 57 ++++++ libs/repositories/src/index.ts | 6 + libs/repositories/src/types.ts | 4 + package.json | 5 +- yarn.lock | 84 +++++++++ 16 files changed, 642 insertions(+), 19 deletions(-) create mode 100644 libs/repositories/src/Erc20Repository/Erc20Repository.ts create mode 100644 libs/repositories/src/Erc20Repository/Erc20RepositoryCache.spec.ts create mode 100644 libs/repositories/src/Erc20Repository/Erc20RepositoryCache.ts create mode 100644 libs/repositories/src/Erc20Repository/Erc20RepositoryViem.spec.ts create mode 100644 libs/repositories/src/Erc20Repository/Erc20RepositoryViem.ts create mode 100644 libs/repositories/src/datasources/viem.ts diff --git a/.env.example b/.env.example index d69d64a..3480c20 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ VESION=1.0.0 # Port PORT=8080 +# RPC urls (http or websocket) +# RPC_URL_1=https://mainnet.infura.io/v3/your-infura-key +# RPC_URL_100=https://rpc.gnosischain.com +# RPC_URL_42161=wss://arbitrum-mainnet.io/ws/v3/your-infura-key +# RPC_URL_11155111=https://sepolia.infura.io/v3/your-infura-key + # Caching REDIS_HOST=redis # REDIS_PORT=6379 diff --git a/apps/api/src/app/inversify.config.ts b/apps/api/src/app/inversify.config.ts index 00e9908..5e2ed81 100644 --- a/apps/api/src/app/inversify.config.ts +++ b/apps/api/src/app/inversify.config.ts @@ -2,19 +2,26 @@ import { CacheRepository, CacheRepositoryMemory, CacheRepositoryRedis, + Erc20Repository, + Erc20RepositoryCache, + Erc20RepositoryViem, UsdRepository, UsdRepositoryCache, UsdRepositoryCoingecko, UsdRepositoryCow, UsdRepositoryFallback, cacheRepositorySymbol, + erc20RepositorySymbol, redisClient, usdRepositorySymbol, + viemClients, } from '@cowprotocol/repositories'; const DEFAULT_CACHE_VALUE_SECONDS = ms('2min') / 1000; // 2min cache time by default for values const DEFAULT_CACHE_NULL_SECONDS = ms('30min') / 1000; // 30min cache time by default for NULL values (when the repository don't know) +const CACHE_TOKEN_INFO_SECONDS = ms('24h') / 1000; // 24h + import { Container } from 'inversify'; import { SlippageService, @@ -26,8 +33,13 @@ import { } from '@cowprotocol/services'; import ms from 'ms'; -function getTokenDecimals(tokenAddress: string): number | null { - return 18; // TODO: Implement!!! +function getErc20Repository(cacheRepository: CacheRepository): Erc20Repository { + return new Erc20RepositoryCache( + new Erc20RepositoryViem(viemClients), + cacheRepository, + 'erc20', + CACHE_TOKEN_INFO_SECONDS + ); } function getCacheRepository(_apiContainer: Container): CacheRepository { @@ -38,9 +50,12 @@ function getCacheRepository(_apiContainer: Container): CacheRepository { return new CacheRepositoryMemory(); } -function getUsdRepositoryCow(cacheRepository: CacheRepository): UsdRepository { +function getUsdRepositoryCow( + cacheRepository: CacheRepository, + erc20Repository: Erc20Repository +): UsdRepository { return new UsdRepositoryCache( - new UsdRepositoryCow(getTokenDecimals), + new UsdRepositoryCow(erc20Repository), cacheRepository, 'usdCow', DEFAULT_CACHE_VALUE_SECONDS, @@ -60,25 +75,33 @@ function getUsdRepositoryCoingecko( ); } -function getUsdRepository(cacheRepository: CacheRepository): UsdRepository { +function getUsdRepository( + cacheRepository: CacheRepository, + erc20Repository: Erc20Repository +): UsdRepository { return new UsdRepositoryFallback([ getUsdRepositoryCoingecko(cacheRepository), - getUsdRepositoryCow(cacheRepository), + getUsdRepositoryCow(cacheRepository, erc20Repository), ]); } function getApiContainer(): Container { const apiContainer = new Container(); - // Repositories const cacheRepository = getCacheRepository(apiContainer); + const erc20Repository = getErc20Repository(cacheRepository); + + apiContainer + .bind(erc20RepositorySymbol) + .toConstantValue(erc20Repository); + apiContainer .bind(cacheRepositorySymbol) .toConstantValue(cacheRepository); apiContainer .bind(usdRepositorySymbol) - .toConstantValue(getUsdRepository(cacheRepository)); + .toConstantValue(getUsdRepository(cacheRepository, erc20Repository)); // Services apiContainer diff --git a/apps/api/src/app/schemas.ts b/apps/api/src/app/schemas.ts index aa98188..3137c86 100644 --- a/apps/api/src/app/schemas.ts +++ b/apps/api/src/app/schemas.ts @@ -1,9 +1,10 @@ +import { ALL_CHAIN_IDS } from '@cowprotocol/repositories'; export const ChainIdSchema = { title: 'Chain ID', description: 'Chain ID', - enum: [1, 100, 42161, 11155111], + enum: ALL_CHAIN_IDS, type: 'integer', -} as const +} as const; -export const ETHEREUM_ADDRESS_PATTERN = "^0x[a-fA-F0-9]{40}$" \ No newline at end of file +export const ETHEREUM_ADDRESS_PATTERN = '^0x[a-fA-F0-9]{40}$'; diff --git a/libs/repositories/src/Erc20Repository/Erc20Repository.ts b/libs/repositories/src/Erc20Repository/Erc20Repository.ts new file mode 100644 index 0000000..9967bb2 --- /dev/null +++ b/libs/repositories/src/Erc20Repository/Erc20Repository.ts @@ -0,0 +1,19 @@ +import { SupportedChainId } from '../types'; + +export const erc20RepositorySymbol = Symbol.for('Erc20Repository'); + +export interface Erc20 { + address: string; + name?: string; + symbol?: string; + decimals?: number; +} + +export interface Erc20Repository { + /** + * Return the ERC20 token information for the given address or null if the token address is not an ERC20 token for the given network. + * @param chainId + * @param tokenAddress + */ + get(chainId: SupportedChainId, tokenAddress: string): Promise; +} diff --git a/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.spec.ts b/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.spec.ts new file mode 100644 index 0000000..4e5c0c2 --- /dev/null +++ b/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.spec.ts @@ -0,0 +1,133 @@ +import { Erc20RepositoryCache } from './Erc20RepositoryCache'; +import { Erc20, Erc20Repository } from './Erc20Repository'; +import { CacheRepository } from '../CacheRepository/CacheRepository'; +import { SupportedChainId } from '../types'; + +describe('Erc20RepositoryCache', () => { + let erc20RepositoryCache: Erc20RepositoryCache; + let mockProxy: jest.Mocked; + let mockCache: jest.Mocked; + + const chainId: SupportedChainId = 1; + const tokenAddress = '0xTokenAddress'; + const cacheName = 'erc20'; + const cacheTimeSeconds = 60; + const baseCacheKey = `repos:${cacheName}`; + + const erc20Data: Erc20 = { + address: '0x1111111111111111111111111111111111111111', + name: 'Token Name', + symbol: 'TKN', + decimals: 18, + }; + + beforeEach(() => { + mockProxy = { + get: jest.fn(), + } as unknown as jest.Mocked; + + mockCache = { + get: jest.fn(), + set: jest.fn(), + } as unknown as jest.Mocked; + + erc20RepositoryCache = new Erc20RepositoryCache( + mockProxy, + mockCache, + cacheName, + cacheTimeSeconds + ); + }); + + it('should return cached value if available', async () => { + // GIVEN: Cached value is available + mockCache.get.mockResolvedValue(JSON.stringify(erc20Data)); + + // WHEN: get is called + const result = await erc20RepositoryCache.get(chainId, tokenAddress); + + // THEN: The cache is called + const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`; + expect(mockCache.get).toHaveBeenCalledWith(cacheKey); + + // THEN: The proxy is not called + expect(mockProxy.get).not.toHaveBeenCalled(); + + // THEN: The cached value is returned + expect(result).toEqual(erc20Data); + }); + + it('should return null if cached value is NULL_VALUE', async () => { + // GIVEN: Cached value is null + mockCache.get.mockResolvedValue('null'); + + // WHEN: get is called + const result = await erc20RepositoryCache.get(chainId, tokenAddress); + + // THEN: The cache is called + const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`; + expect(mockCache.get).toHaveBeenCalledWith(cacheKey); + + // THEN: The proxy is not called + expect(mockProxy.get).not.toHaveBeenCalled(); + + // THEN: null is returned + expect(result).toBeNull(); + }); + + it('should fetch from proxy and cache the result if not cached', async () => { + // GIVEN: Cached value is not available + mockCache.get.mockResolvedValue(null); + + // GIVEN: Proxy returns the value + mockProxy.get.mockResolvedValue(erc20Data); + + // WHEN: get is called + const result = await erc20RepositoryCache.get(chainId, tokenAddress); + + // THEN: The cache is called + const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`; + expect(mockCache.get).toHaveBeenCalledWith(cacheKey); + + // THEN: The proxy is called + expect(mockProxy.get).toHaveBeenCalledWith(chainId, tokenAddress); + + // THEN: The value is cached + expect(mockCache.set).toHaveBeenCalledWith( + cacheKey, + JSON.stringify(erc20Data), + cacheTimeSeconds + ); + + // THEN: The value is returned + expect(result).toEqual(erc20Data); + }); + + it('should cache NULL_VALUE if proxy returns null', async () => { + // GIVEN: Cached value is not available + mockCache.get.mockResolvedValue(null); + + // GIVEN: Proxy returns null + mockProxy.get.mockResolvedValue(null); + + // WHEN: get is called + const result = await erc20RepositoryCache.get(chainId, tokenAddress); + + // THEN: The cache is called + const cacheKey = `${baseCacheKey}:get:${chainId}:${tokenAddress}`; + expect(mockCache.get).toHaveBeenCalledWith(cacheKey); + + // THEN: The proxy is called + expect(mockProxy.get).toHaveBeenCalledWith(chainId, tokenAddress); + + // THEN: The null value is cached + expect(mockCache.set).toHaveBeenCalledWith( + cacheKey, + 'null', + cacheTimeSeconds + ); + + // THEN: The result is null + expect(result).toBeNull(); + }); +}); diff --git a/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.ts b/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.ts new file mode 100644 index 0000000..147512d --- /dev/null +++ b/libs/repositories/src/Erc20Repository/Erc20RepositoryCache.ts @@ -0,0 +1,43 @@ +import { inject, injectable } from 'inversify'; +import NodeCache from 'node-cache'; +import { Erc20, Erc20Repository } from './Erc20Repository'; +import { CacheRepository } from '../CacheRepository/CacheRepository'; +import { SupportedChainId } from '../types'; + +const NULL_VALUE = 'null'; + +@injectable() +export class Erc20RepositoryCache implements Erc20Repository { + private baseCacheKey: string; + + constructor( + private proxy: Erc20Repository, + private cache: CacheRepository, + private cacheName: string, + private cacheTimeSeconds: number + ) { + this.baseCacheKey = `repos:${this.cacheName}`; + } + + async get( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + const cacheKey = `${this.baseCacheKey}:get:${chainId}:${tokenAddress}`; + + // Get cached value + const valueString = await this.cache.get(cacheKey); + if (valueString) { + return valueString === NULL_VALUE ? null : JSON.parse(valueString); + } + + // Get fresh value from proxy + const value = await this.proxy.get(chainId, tokenAddress); + + // Cache value + const cacheValue = value === null ? NULL_VALUE : JSON.stringify(value); + await this.cache.set(cacheKey, cacheValue, this.cacheTimeSeconds); + + return value; + } +} diff --git a/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.spec.ts b/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.spec.ts new file mode 100644 index 0000000..081cc3b --- /dev/null +++ b/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.spec.ts @@ -0,0 +1,167 @@ +import { Erc20RepositoryViem } from './Erc20RepositoryViem'; +import { SupportedChainId } from '../types'; +import { PublicClient } from 'viem'; +import { erc20Abi } from 'viem'; +import { Erc20 } from './Erc20Repository'; + +jest.mock('viem', () => ({ + ...jest.requireActual('viem'), + PublicClient: jest.fn().mockImplementation(() => ({ + getCode: jest.fn(), + multicall: jest.fn(), + })), +})); + +const multicallMock = jest.fn(); + +// Mock implementation for PublicClient +const mockPublicClient: PublicClient = { + multicall: multicallMock, + + // Add other methods of PublicClient if needed +} as unknown as jest.Mocked; + +export default mockPublicClient; + +describe('Erc20RepositoryViem', () => { + let viemClients: Record; + let erc20RepositoryViem: Erc20RepositoryViem; + + const chainId = SupportedChainId.MAINNET; + const tokenAddress = '0x1111111111111111111111111111111111111111'; + const error = new Error('Multicall error'); + const erc20: Erc20 = { + address: tokenAddress, + name: 'Token Name', + symbol: 'TKN', + decimals: 18, + }; + const expectedMulticallParams = { + contracts: [ + { address: tokenAddress, abi: erc20Abi, functionName: 'totalSupply' }, + { address: tokenAddress, abi: erc20Abi, functionName: 'name' }, + { address: tokenAddress, abi: erc20Abi, functionName: 'symbol' }, + { address: tokenAddress, abi: erc20Abi, functionName: 'decimals' }, + ], + }; + + beforeEach(() => { + viemClients = { + [SupportedChainId.MAINNET]: mockPublicClient, + [SupportedChainId.GNOSIS_CHAIN]: mockPublicClient, + [SupportedChainId.ARBITRUM_ONE]: mockPublicClient, + [SupportedChainId.SEPOLIA]: mockPublicClient, + }; + erc20RepositoryViem = new Erc20RepositoryViem(viemClients); + }); + + it('should return null if the address has no totalSupply (is not a ERC20)', async () => { + // GIVEN: The address is not a ERC20. The multicall fails for totalSupply (and all other calls) + multicallMock.mockResolvedValue([ + { error, status: 'failure' }, // totalSupply + { error, status: 'failure' }, // name + { error, status: 'failure' }, // symbol + { error, status: 'failure' }, // decimals + ]); + + // WHEN: get is called + const result = await erc20RepositoryViem.get(chainId, tokenAddress); + + // THEN: Multicall called with the correct parameters + expect(viemClients[chainId].multicall).toHaveBeenCalledWith( + expectedMulticallParams + ); + + // THEN: The result is null + expect(result).toBeNull(); + }); + + it('should return Erc20 token details if address is an ERC20', async () => { + // GIVEN: The address is a ERC20, but has no symbol or any other details + multicallMock.mockResolvedValue([ + { result: 1234567, status: 'success' }, // totalSupply + { error, status: 'failure' }, // name + { error, status: 'failure' }, // symbol + { error, status: 'failure' }, // decimals + ]); + + // WHEN: get is called + const result = await erc20RepositoryViem.get(chainId, tokenAddress); + + // THEN: Multicall called with the correct parameters + expect(viemClients[chainId].multicall).toHaveBeenCalledWith( + expectedMulticallParams + ); + + // THEN: The result is the token details + expect(result).toEqual({ + address: tokenAddress, + name: undefined, + symbol: undefined, + decimals: undefined, + }); + }); + + it('should return symbol if its the only optional method implemented', async () => { + // GIVEN: The address is a ERC20, but has no symbol or any other details + multicallMock.mockResolvedValue([ + { result: 1234567, status: 'success' }, // totalSupply + { error, status: 'failure' }, // name + { result: erc20.symbol, status: 'success' }, // symbol + { error, status: 'failure' }, // decimals + ]); + + // WHEN: get is called + const result = await erc20RepositoryViem.get(chainId, tokenAddress); + + // THEN: Multicall called with the correct parameters + expect(viemClients[chainId].multicall).toHaveBeenCalledWith( + expectedMulticallParams + ); + + // THEN: The result is the token details + expect(result).toEqual({ + address: tokenAddress, + symbol: erc20.symbol, + name: undefined, + decimals: undefined, + }); + }); + + it('should return all ERC20 fields', async () => { + // GIVEN: The address is a ERC20, but has no symbol or any other details + multicallMock.mockResolvedValue([ + { result: 1234567, status: 'success' }, // totalSupply + { result: erc20.name, status: 'success' }, // name + { result: erc20.symbol, status: 'success' }, // symbol + { result: erc20.decimals, status: 'success' }, // decimals + ]); + + // WHEN: get is called + const result = await erc20RepositoryViem.get(chainId, tokenAddress); + + // THEN: Multicall called with the correct parameters + expect(viemClients[chainId].multicall).toHaveBeenCalledWith( + expectedMulticallParams + ); + + // THEN: The result is the token details + expect(result).toEqual(erc20); + }); + + it('should handle multicall errors', async () => { + // GIVEN: The multicall throws an error + multicallMock.mockRejectedValue(error); + + // WHEN: get is called + const resultPromise = erc20RepositoryViem.get(chainId, tokenAddress); + + // THEN: Multicall called with the correct parameters + expect(viemClients[chainId].multicall).toHaveBeenCalledWith( + expectedMulticallParams + ); + + // THEN: The result should rethrow the original error + expect(resultPromise).rejects.toThrowError(error); + }); +}); diff --git a/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.ts b/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.ts new file mode 100644 index 0000000..2794270 --- /dev/null +++ b/libs/repositories/src/Erc20Repository/Erc20RepositoryViem.ts @@ -0,0 +1,65 @@ +import { injectable } from 'inversify'; +import { Erc20, Erc20Repository } from './Erc20Repository'; +import { SupportedChainId } from '../types'; +import { erc20Abi, getAddress, PublicClient } from 'viem'; + +@injectable() +export class Erc20RepositoryViem implements Erc20Repository { + constructor(private viemClients: Record) {} + + async get( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + const viemClient = this.viemClients[chainId]; + const tokenAddressHex = getAddress(tokenAddress); + + const ercTokenParams = { + address: tokenAddressHex, + abi: erc20Abi, + }; + + const [totalSupplyResult, nameResult, symbolResult, decimalsResult] = + await viemClient.multicall({ + contracts: [ + { + ...ercTokenParams, + functionName: 'totalSupply', + }, + { + ...ercTokenParams, + functionName: 'name', + }, + { + ...ercTokenParams, + functionName: 'symbol', + }, + { + ...ercTokenParams, + functionName: 'decimals', + }, + ], + }); + + // If the total supply fails, the token is not an ERC20 + if (totalSupplyResult.status === 'failure') { + return null; + } + + const name = + nameResult.status === 'success' ? nameResult.result : undefined; + + const symbol = + symbolResult.status === 'success' ? symbolResult.result : undefined; + + const decimals = + decimalsResult.status === 'success' ? decimalsResult.result : undefined; + + return { + address: tokenAddress, + name, + symbol, + decimals, + }; + } +} diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCache.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCache.ts index b20ca22..e8cd922 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCache.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCache.ts @@ -19,7 +19,7 @@ export class UsdRepositoryCache implements UsdRepository { constructor( private proxy: UsdRepository, - @inject(usdRepositorySymbol) private cache: CacheRepository, + private cache: CacheRepository, private cacheName: string, private cacheTimeValueSeconds: number, private cacheTimeNullSeconds: number diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts index 6b026e5..4ac403d 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts @@ -9,6 +9,7 @@ import { okResponse, } from '../../test/mock'; import { USDC } from '../const'; +import { Erc20Repository, Erc20 } from '../Erc20Repository/Erc20Repository'; function getTokenDecimalsMock(tokenAddress: string) { return tokenAddress === WETH ? 18 : 6; @@ -18,7 +19,20 @@ 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 mockErc20Repository = { + async get( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + const decimals = tokenAddress === WETH ? 18 : 6; + return { + address: tokenAddress, + decimals, + }; + }, +} as jest.Mocked; + +const usdRepositoryCow = new UsdRepositoryCow(mockErc20Repository); const cowApiMock = jest.spyOn(cowApiClientMainnet, 'GET'); diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts index 4c3f507..35bb0fa 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCow.ts @@ -5,12 +5,11 @@ import { OneBigNumber, TenBigNumber, USDC, ZeroBigNumber } from '../const'; import { SupportedChainId } from '../types'; import { BigNumber } from 'bignumber.js'; import { throwIfUnsuccessful } from '../utils/throwIfUnsuccessful'; +import { Erc20Repository } from '../Erc20Repository/Erc20Repository'; @injectable() export class UsdRepositoryCow extends UsdRepositoryNoop { - constructor( - private getTokenDecimals: (tokenAddress: string) => number | null - ) { + constructor(private erc20Repository: Erc20Repository) { super(); } @@ -23,7 +22,8 @@ export class UsdRepositoryCow extends UsdRepositoryNoop { if (!tokenNativePrice) { return null; } - const tokenDecimals = this.getTokenDecimals(tokenAddress); + const erc20 = await this.erc20Repository.get(chainId, tokenAddress); + const tokenDecimals = erc20?.decimals || 18; if (tokenDecimals === null) { throw new Error('Token decimals not found for ' + tokenAddress); } diff --git a/libs/repositories/src/datasources/viem.ts b/libs/repositories/src/datasources/viem.ts new file mode 100644 index 0000000..08f8995 --- /dev/null +++ b/libs/repositories/src/datasources/viem.ts @@ -0,0 +1,57 @@ +import { + createPublicClient, + http, + Client, + Chain, + PublicClient, + webSocket, +} from 'viem'; +import { arbitrum, gnosis, mainnet, sepolia } from 'viem/chains'; +import { ALL_CHAIN_IDS, SupportedChainId } from '../types'; + +const NETWORKS: Record = { + [SupportedChainId.MAINNET]: mainnet, + [SupportedChainId.GNOSIS_CHAIN]: gnosis, + [SupportedChainId.ARBITRUM_ONE]: arbitrum, + [SupportedChainId.SEPOLIA]: sepolia, +}; + +export const viemClients = ALL_CHAIN_IDS.reduce< + Record +>((acc, chainId) => { + const chain = NETWORKS[chainId]; + const rpcEndpoint = process.env[`RPC_URL_${chainId}`]; + const defaultRpcUrls = getDefaultRpcUrl(chain, rpcEndpoint); + + acc[chainId] = createPublicClient({ + chain: { + ...chain, + rpcUrls: { + default: defaultRpcUrls, + }, + }, + transport: defaultRpcUrls.webSocket ? webSocket() : http(), + }); + + return acc; +}, {} as Record); + +function getDefaultRpcUrl( + chain: Chain, + rpcEndpoint?: string +): Chain['rpcUrls']['default'] { + if (!rpcEndpoint) { + return chain.rpcUrls.default; + } + + if (rpcEndpoint.startsWith('http')) { + return { + http: [rpcEndpoint], + }; + } + + return { + http: chain.rpcUrls.default.http, + webSocket: [rpcEndpoint], + }; +} diff --git a/libs/repositories/src/index.ts b/libs/repositories/src/index.ts index 5775f40..6a334a8 100644 --- a/libs/repositories/src/index.ts +++ b/libs/repositories/src/index.ts @@ -1,5 +1,6 @@ export * from './types'; export * from './datasources/redis'; +export * from './datasources/viem'; // Data sources export { COINGECKO_PRO_BASE_URL } from './datasources/coingecko'; @@ -9,6 +10,11 @@ export * from './CacheRepository/CacheRepository'; export * from './CacheRepository/CacheRepositoryMemory'; export * from './CacheRepository/CacheRepositoryRedis'; +// Erc20Repository +export * from './Erc20Repository/Erc20Repository'; +export * from './Erc20Repository/Erc20RepositoryCache'; +export * from './Erc20Repository/Erc20RepositoryViem'; + // USD repositories export * from './UsdRepository/UsdRepository'; export * from './UsdRepository/UsdRepositoryCoingecko'; diff --git a/libs/repositories/src/types.ts b/libs/repositories/src/types.ts index aecbf03..3906e70 100644 --- a/libs/repositories/src/types.ts +++ b/libs/repositories/src/types.ts @@ -5,3 +5,7 @@ export enum SupportedChainId { ARBITRUM_ONE = 42161, SEPOLIA = 11155111, } + +export const ALL_CHAIN_IDS: SupportedChainId[] = Object.values(SupportedChainId) + .filter((value) => typeof value === 'number') // Filter out non-numeric values + .map((value) => value as number); // Map to number diff --git a/package.json b/package.json index 5076061..f75944a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "reflect-metadata": "^0.2.2", "tslib": "^2.3.0", "typeorm": "^0.3.17", - "typeorm-fastify-plugin": "^1.0.5" + "typeorm-fastify-plugin": "^1.0.5", + "viem": "^2.18.4" }, "devDependencies": { "@nx/esbuild": "16.3.2", @@ -98,4 +99,4 @@ "vite-plugin-dts": "^3.0.3", "vite-tsconfig-paths": "^4.2.0" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 3b5a385..bdb4695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,6 +16,11 @@ debug "^4.3.4" safe-buffer "~5.1.2" +"@adraffy/ens-normalize@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz#d2a39395c587e092d77cbbc80acf956a54f38bf7" + integrity sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q== + "@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz" @@ -2215,11 +2220,30 @@ resolved "https://registry.npmjs.org/@nicolo-ribaudo/semver-v6/-/semver-v6-6.3.3.tgz" integrity sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg== +"@noble/curves@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.0.tgz#f05771ef64da724997f69ee1261b2417a49522d6" + integrity sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg== + dependencies: + "@noble/hashes" "1.4.0" + +"@noble/curves@^1.4.0", "@noble/curves@~1.4.0": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" + integrity sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw== + dependencies: + "@noble/hashes" "1.4.0" + "@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.2.0.tgz" integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== +"@noble/hashes@1.4.0", "@noble/hashes@^1.4.0", "@noble/hashes@~1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + "@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0": version "1.7.1" resolved "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz" @@ -2766,6 +2790,11 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz" integrity sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g== +"@scure/base@~1.1.6": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.7.tgz#fe973311a5c6267846aa131bc72e96c5d40d2b30" + integrity sha512-PPNYBslrLNNUQ/Yad37MHYsNQtK67EhWb6WtSvNLLPo7SdVZgkUjD6Dg+5On7zNwmskf8OX7I7Nx5oN+MIWE0g== + "@scure/bip32@1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-1.1.5.tgz" @@ -2775,6 +2804,15 @@ "@noble/secp256k1" "~1.7.0" "@scure/base" "~1.1.0" +"@scure/bip32@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.4.0.tgz#4e1f1e196abedcef395b33b9674a042524e20d67" + integrity sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg== + dependencies: + "@noble/curves" "~1.4.0" + "@noble/hashes" "~1.4.0" + "@scure/base" "~1.1.6" + "@scure/bip39@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.1.tgz" @@ -2783,6 +2821,14 @@ "@noble/hashes" "~1.2.0" "@scure/base" "~1.1.0" +"@scure/bip39@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.3.0.tgz#0f258c16823ddd00739461ac31398b4e7d6a18c3" + integrity sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ== + dependencies: + "@noble/hashes" "~1.4.0" + "@scure/base" "~1.1.6" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz" @@ -3226,6 +3272,11 @@ dependencies: argparse "^2.0.1" +abitype@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-1.0.5.tgz#29d0daa3eea867ca90f7e4123144c1d1270774b6" + integrity sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -5806,6 +5857,11 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isows@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/isows/-/isows-1.0.4.tgz#810cd0d90cc4995c26395d2aa4cfa4037ebdf061" + integrity sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ== + isstream@~0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" @@ -8594,6 +8650,21 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +viem@^2.18.4: + version "2.18.4" + resolved "https://registry.yarnpkg.com/viem/-/viem-2.18.4.tgz#6af97b68141faa68f624b7cded4956065432a078" + integrity sha512-JGdN+PgBnZMbm7fc9o0SfHvL0CKyfrlhBUtaz27V+PeHO43Kgc9Zd4WyIbM8Brafq4TvVcnriRFW/FVGOzwEJw== + dependencies: + "@adraffy/ens-normalize" "1.10.0" + "@noble/curves" "1.4.0" + "@noble/hashes" "1.4.0" + "@scure/bip32" "1.4.0" + "@scure/bip39" "1.3.0" + abitype "1.0.5" + isows "1.0.4" + webauthn-p256 "0.0.5" + ws "8.17.1" + vite-plugin-dts@^3.0.3: version "3.1.0" resolved "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-3.1.0.tgz" @@ -8659,6 +8730,14 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== +webauthn-p256@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/webauthn-p256/-/webauthn-p256-0.0.5.tgz#0baebd2ba8a414b21cc09c0d40f9dd0be96a06bd" + integrity sha512-drMGNWKdaixZNobeORVIqq7k5DsRC9FnG201K2QjeOoQLmtSDaSsVZdkg6n5jUALJKcAG++zBPJXmv6hy0nWFg== + dependencies: + "@noble/curves" "^1.4.0" + "@noble/hashes" "^1.4.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" @@ -8736,6 +8815,11 @@ ws@7.4.6: resolved "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + ws@^8.4.2: version "8.13.0" resolved "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz"