From 4a913cb9f72205fa3251db8cb50e63ae3840f3c2 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari <55461956+yvesfracari@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:07:36 -0300 Subject: [PATCH] feat: add simulation and token holder endpoints (#88) * add token holder repository * feat: add token holder endpoint * add tenderly bundle simulation endpoint * refactor: remove unused code * feat: add ethplorer token holder repository * add null value cache on token holder * chore: add fallback strategy on token holder * refactor: add generic fallback repository * refactor: add generic cache repository factory * revert: usdPrice changes * refactor: rename tenderly endpoint to simulation * rename tenderly repository and service to simulation * refactor cache factory to receive converfns * fix import typo * revert refactoring to the fallback and cache factories --- .env.example | 13 +- apps/api/src/app/inversify.config.ts | 65 ++ apps/api/src/app/plugins/env.ts | 15 + .../__chainId/simulation/simulateBundle.ts | 138 +++++ .../tokens/__tokenAddress/topHolders.ts | 98 +++ .../tokens/__tokenAddress/usdPrice.ts | 16 +- apps/api/src/app/schemas.ts | 7 + .../SimulationRepository.ts | 25 + .../SimulationRepositoryTenderly.ts | 64 ++ .../SimulationrepositoryTenderly.test.ts | 83 +++ .../src/SimulationRepository/tenderlyTypes.ts | 574 ++++++++++++++++++ .../TokenHolderRepository.ts | 25 + .../TokenHolderRepositoryCache.spec.ts | 183 ++++++ .../TokenHolderRepositoryCache.ts | 87 +++ .../TokenHolderRepositoryEthplorer.test.ts | 67 ++ .../TokenHolderRepositoryEthplorer.ts | 63 ++ .../TokenHolderRepositoryFallback.spec.ts | 116 ++++ .../TokenHolderRepositoryFallback.ts | 27 + .../TokenHolderRepositoryGoldRush.test.ts | 47 ++ .../TokenHolderRepositoryGoldRush.ts | 67 ++ .../UsdRepositoryCoingecko.test.ts | 6 +- .../UsdRepository/UsdRepositoryCow.spec.ts | 9 +- .../repositories/src/datasources/ethplorer.ts | 10 + libs/repositories/src/datasources/goldRush.ts | 14 + .../src/datasources/tenderlyApi.ts | 10 + libs/repositories/src/index.ts | 12 + libs/repositories/test/mock.ts | 3 +- .../SimulationService/SimulationService.ts | 28 + .../TokenHolderService/TokenHolderService.ts | 31 + libs/services/src/index.ts | 4 + 30 files changed, 1880 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/app/routes/__chainId/simulation/simulateBundle.ts create mode 100644 apps/api/src/app/routes/__chainId/tokens/__tokenAddress/topHolders.ts create mode 100644 libs/repositories/src/SimulationRepository/SimulationRepository.ts create mode 100644 libs/repositories/src/SimulationRepository/SimulationRepositoryTenderly.ts create mode 100644 libs/repositories/src/SimulationRepository/SimulationrepositoryTenderly.test.ts create mode 100644 libs/repositories/src/SimulationRepository/tenderlyTypes.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepository.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.spec.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.test.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.spec.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.test.ts create mode 100644 libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.ts create mode 100644 libs/repositories/src/datasources/ethplorer.ts create mode 100644 libs/repositories/src/datasources/goldRush.ts create mode 100644 libs/repositories/src/datasources/tenderlyApi.ts create mode 100644 libs/services/src/SimulationService/SimulationService.ts create mode 100644 libs/services/src/TokenHolderService/TokenHolderService.ts diff --git a/.env.example b/.env.example index 0b652d7..c309f68 100644 --- a/.env.example +++ b/.env.example @@ -66,4 +66,15 @@ # JWT_CERT_PASSPHRASE=secret # Authorized domains -# AUTHORIZED_ORIGINS=cow.fi \ No newline at end of file +# AUTHORIZED_ORIGINS=cow.fi + +# GOLD RUSH +# GOLD_RUSH_API_KEY= + +# TENDERLY +# TENDERLY_API_KEY= +# TENDERLY_ORG_NAME= +# TENDERLY_PROJECT_NAME= + +# ETHPLORER +# ETHPLORER_API_KEY= \ No newline at end of file diff --git a/apps/api/src/app/inversify.config.ts b/apps/api/src/app/inversify.config.ts index 5f29f75..ab9ca4f 100644 --- a/apps/api/src/app/inversify.config.ts +++ b/apps/api/src/app/inversify.config.ts @@ -5,6 +5,13 @@ import { Erc20Repository, Erc20RepositoryCache, Erc20RepositoryViem, + SimulationRepository, + SimulationRepositoryTenderly, + TokenHolderRepository, + TokenHolderRepositoryCache, + TokenHolderRepositoryEthplorer, + TokenHolderRepositoryFallback, + TokenHolderRepositoryGoldRush, UsdRepository, UsdRepositoryCache, UsdRepositoryCoingecko, @@ -14,6 +21,8 @@ import { cowApiClients, erc20RepositorySymbol, redisClient, + tenderlyRepositorySymbol, + tokenHolderRepositorySymbol, usdRepositorySymbol, viemClients, } from '@cowprotocol/repositories'; @@ -25,11 +34,16 @@ const CACHE_TOKEN_INFO_SECONDS = ms('24h') / 1000; // 24h import { Container } from 'inversify'; import { + SimulationService, SlippageService, SlippageServiceMain, + TokenHolderService, + TokenHolderServiceMain, UsdService, UsdServiceMain, + simulationServiceSymbol, slippageServiceSymbol, + tokenHolderServiceSymbol, usdServiceSymbol, } from '@cowprotocol/services'; import ms from 'ms'; @@ -86,16 +100,55 @@ function getUsdRepository( ]); } +function getTokenHolderRepositoryEthplorer( + cacheRepository: CacheRepository +): TokenHolderRepository { + return new TokenHolderRepositoryCache( + new TokenHolderRepositoryEthplorer(), + cacheRepository, + 'tokenHolderEthplorer', + DEFAULT_CACHE_VALUE_SECONDS, + DEFAULT_CACHE_NULL_SECONDS + ); +} + +function getTokenHolderRepositoryGoldRush( + cacheRepository: CacheRepository +): TokenHolderRepository { + return new TokenHolderRepositoryCache( + new TokenHolderRepositoryGoldRush(), + cacheRepository, + 'tokenHolderGoldRush', + DEFAULT_CACHE_VALUE_SECONDS, + DEFAULT_CACHE_NULL_SECONDS + ); +} + +function getTokenHolderRepository( + cacheRepository: CacheRepository +): TokenHolderRepository { + return new TokenHolderRepositoryFallback([ + getTokenHolderRepositoryGoldRush(cacheRepository), + getTokenHolderRepositoryEthplorer(cacheRepository), + ]); +} + function getApiContainer(): Container { const apiContainer = new Container(); // Repositories const cacheRepository = getCacheRepository(apiContainer); const erc20Repository = getErc20Repository(cacheRepository); + const simulationRepository = new SimulationRepositoryTenderly(); + const tokenHolderRepository = getTokenHolderRepository(cacheRepository); apiContainer .bind(erc20RepositorySymbol) .toConstantValue(erc20Repository); + apiContainer + .bind(tenderlyRepositorySymbol) + .toConstantValue(simulationRepository); + apiContainer .bind(cacheRepositorySymbol) .toConstantValue(cacheRepository); @@ -104,13 +157,25 @@ function getApiContainer(): Container { .bind(usdRepositorySymbol) .toConstantValue(getUsdRepository(cacheRepository, erc20Repository)); + apiContainer + .bind(tokenHolderRepositorySymbol) + .toConstantValue(tokenHolderRepository); + // Services apiContainer .bind(slippageServiceSymbol) .to(SlippageServiceMain); + apiContainer + .bind(tokenHolderServiceSymbol) + .to(TokenHolderServiceMain); + apiContainer.bind(usdServiceSymbol).to(UsdServiceMain); + apiContainer + .bind(simulationServiceSymbol) + .to(SimulationService); + return apiContainer; } diff --git a/apps/api/src/app/plugins/env.ts b/apps/api/src/app/plugins/env.ts index e607107..6e938c9 100644 --- a/apps/api/src/app/plugins/env.ts +++ b/apps/api/src/app/plugins/env.ts @@ -23,6 +23,21 @@ const schema = { COINGECKO_API_KEY: { type: 'string', }, + GOLD_RUSH_API_KEY: { + type: 'string', + }, + TENDERLY_API_KEY: { + type: 'string', + }, + TENDERLY_PROJECT: { + type: 'string', + }, + TENDERLY_ACCOUNT: { + type: 'string', + }, + ETHPLORER_API_KEY: { + type: 'string', + }, }, }; diff --git a/apps/api/src/app/routes/__chainId/simulation/simulateBundle.ts b/apps/api/src/app/routes/__chainId/simulation/simulateBundle.ts new file mode 100644 index 0000000..160546c --- /dev/null +++ b/apps/api/src/app/routes/__chainId/simulation/simulateBundle.ts @@ -0,0 +1,138 @@ +import { + SimulationService, + simulationServiceSymbol, +} from '@cowprotocol/services'; +import { FastifyPluginAsync } from 'fastify'; +import { FromSchema, JSONSchema } from 'json-schema-to-ts'; +import { AddressSchema, ChainIdSchema } from '../../../schemas'; +import { apiContainer } from '../../../inversify.config'; + +const paramsSchema = { + type: 'object', + required: ['chainId'], + additionalProperties: false, + properties: { + chainId: ChainIdSchema, + }, +} as const satisfies JSONSchema; + +const successSchema = { + type: 'array', + items: { + type: 'object', + required: ['status', 'id', 'link'], + additionalProperties: false, + properties: { + status: { + title: 'Status', + description: 'If the transaction was successful.', + type: 'boolean', + }, + id: { + title: 'ID', + description: 'Tenderly ID of the transaction.', + type: 'string', + }, + link: { + title: 'Link', + description: 'Link to the transaction on Tenderly.', + type: 'string', + }, + }, + }, +} as const satisfies JSONSchema; + +const bodySchema = { + type: 'array', + items: { + type: 'object', + required: ['from', 'to', 'input'], + additionalProperties: false, + properties: { + from: AddressSchema, + to: AddressSchema, + value: { + title: 'Value', + description: 'Amount of native coin to send.', + type: 'string', + }, + input: { + title: 'Input', + description: 'Transaction data.', + type: 'string', + }, + gas: { + title: 'Gas', + description: 'Transaction gas limit.', + type: 'number', + }, + gas_price: { + title: 'Gas price', + description: 'Gas price.', + type: 'string', + }, + }, + }, +} as const satisfies JSONSchema; + +const errorSchema = { + type: 'object', + required: ['message'], + additionalProperties: false, + properties: { + message: { + title: 'Message', + description: 'Message describing the error.', + type: 'string', + }, + }, +} as const satisfies JSONSchema; + +type RouteSchema = FromSchema; +type SuccessSchema = FromSchema; +type ErrorSchema = FromSchema; +type BodySchema = FromSchema; + +const tenderlyService: SimulationService = apiContainer.get( + simulationServiceSymbol +); + +const root: FastifyPluginAsync = async (fastify): Promise => { + fastify.post<{ + Params: RouteSchema; + Reply: SuccessSchema | ErrorSchema; + Body: BodySchema; + }>( + '/simulateBundle', + { + schema: { + params: paramsSchema, + response: { + '2XX': successSchema, + '404': errorSchema, + }, + }, + }, + async function (request, reply) { + const { chainId } = request.params; + + const simulationResult = + await tenderlyService.postTenderlyBundleSimulation( + chainId, + request.body + ); + + if (simulationResult === null) { + reply.code(404).send({ message: 'Token holders not found' }); + return; + } + fastify.log.info( + `Post Tenderly bundle of ${request.body.length} simulation on chain ${chainId}` + ); + + reply.send(simulationResult); + } + ); +}; + +export default root; diff --git a/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/topHolders.ts b/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/topHolders.ts new file mode 100644 index 0000000..8e28564 --- /dev/null +++ b/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/topHolders.ts @@ -0,0 +1,98 @@ +import { + TokenHolderService, + tokenHolderServiceSymbol, +} from '@cowprotocol/services'; +import { AddressSchema, ChainIdSchema } from '../../../../schemas'; +import { FastifyPluginAsync } from 'fastify'; +import { FromSchema, JSONSchema } from 'json-schema-to-ts'; +import { apiContainer } from '../../../../inversify.config'; + +const paramsSchema = { + type: 'object', + required: ['chainId', 'tokenAddress'], + additionalProperties: false, + properties: { + chainId: ChainIdSchema, + tokenAddress: AddressSchema, + }, +} as const satisfies JSONSchema; + +const successSchema = { + type: 'array', + items: { + type: 'object', + required: ['address', 'balance'], + additionalProperties: false, + properties: { + address: { + title: 'Address', + description: 'Address of the token holder.', + type: 'string', + }, + balance: { + title: 'Balance', + description: 'Balance of the token holder.', + type: 'string', + }, + }, + }, +} as const satisfies JSONSchema; + +const errorSchema = { + type: 'object', + required: ['message'], + additionalProperties: false, + properties: { + message: { + title: 'Message', + description: 'Message describing the error.', + type: 'string', + }, + }, +} as const satisfies JSONSchema; + +type RouteSchema = FromSchema; +type SuccessSchema = FromSchema; +type ErrorSchema = FromSchema; + +const tokenHolderService: TokenHolderService = apiContainer.get( + tokenHolderServiceSymbol +); + +const root: FastifyPluginAsync = async (fastify): Promise => { + // example: http://localhost:3010/1/tokens/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/topHolders + fastify.get<{ + Params: RouteSchema; + Reply: SuccessSchema | ErrorSchema; + }>( + '/topHolders', + { + schema: { + params: paramsSchema, + response: { + '2XX': successSchema, + '404': errorSchema, + }, + }, + }, + async function (request, reply) { + const { chainId, tokenAddress } = request.params; + + const tokenHolders = await tokenHolderService.getTopTokenHolders( + chainId, + tokenAddress + ); + fastify.log.info( + `Get token holders for ${tokenAddress} on chain ${chainId}: ${tokenHolders?.length} holder found` + ); + if (tokenHolders === null) { + reply.code(404).send({ message: 'Token holders not found' }); + return; + } + + reply.send(tokenHolders); + } + ); +}; + +export default root; diff --git a/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/usdPrice.ts b/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/usdPrice.ts index bfc2610..b1667f9 100644 --- a/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/usdPrice.ts +++ b/apps/api/src/app/routes/__chainId/tokens/__tokenAddress/usdPrice.ts @@ -1,28 +1,16 @@ import { UsdService, usdServiceSymbol } from '@cowprotocol/services'; -import { ChainIdSchema, ETHEREUM_ADDRESS_PATTERN } from '../../../../schemas'; +import { AddressSchema, ChainIdSchema } from '../../../../schemas'; import { FastifyPluginAsync } from 'fastify'; import { FromSchema, JSONSchema } from 'json-schema-to-ts'; import { apiContainer } from '../../../../inversify.config'; -// TODO: Add this in a follow up PR -// import { ALL_SUPPORTED_CHAIN_IDS } from '@cowprotocol/cow-sdk'; - -interface Result { - price: number; -} - const paramsSchema = { type: 'object', required: ['chainId', 'tokenAddress'], additionalProperties: false, properties: { chainId: ChainIdSchema, - tokenAddress: { - title: 'Token address', - description: 'Token address.', - type: 'string', - pattern: ETHEREUM_ADDRESS_PATTERN, - }, + tokenAddress: AddressSchema, }, } as const satisfies JSONSchema; diff --git a/apps/api/src/app/schemas.ts b/apps/api/src/app/schemas.ts index 05a2946..26241be 100644 --- a/apps/api/src/app/schemas.ts +++ b/apps/api/src/app/schemas.ts @@ -7,4 +7,11 @@ export const ChainIdSchema = { type: 'integer', } as const; +export const AddressSchema = { + title: 'Address', + description: 'Ethereum address.', + type: 'string', + pattern: '^0x[a-fA-F0-9]{40}$', +} as const; + export const ETHEREUM_ADDRESS_PATTERN = '^0x[a-fA-F0-9]{40}$'; diff --git a/libs/repositories/src/SimulationRepository/SimulationRepository.ts b/libs/repositories/src/SimulationRepository/SimulationRepository.ts new file mode 100644 index 0000000..7f07a40 --- /dev/null +++ b/libs/repositories/src/SimulationRepository/SimulationRepository.ts @@ -0,0 +1,25 @@ +import { SupportedChainId } from '@cowprotocol/shared'; + +export interface SimulationInput { + input: string; + from: string; + to: string; + value?: string; + gas?: number; + gas_price?: string; +} + +export interface SimulationData { + link: string; + status: boolean; + id: string; +} + +export const simulationRepositorySymbol = Symbol.for('SimulationRepository'); + +export interface SimulationRepository { + postBundleSimulation( + chainId: SupportedChainId, + simulationsInput: SimulationInput[] + ): Promise; +} diff --git a/libs/repositories/src/SimulationRepository/SimulationRepositoryTenderly.ts b/libs/repositories/src/SimulationRepository/SimulationRepositoryTenderly.ts new file mode 100644 index 0000000..ab7b470 --- /dev/null +++ b/libs/repositories/src/SimulationRepository/SimulationRepositoryTenderly.ts @@ -0,0 +1,64 @@ +import { SupportedChainId } from '@cowprotocol/shared'; +import { + SimulationError, + TenderlyBundleSimulationResponse, + TenderlySimulatePayload, +} from './tenderlyTypes'; +import { + getTenderlySimulationLink, + TENDERLY_API_BASE_ENDPOINT, + TENDERLY_API_KEY, +} from '../datasources/tenderlyApi'; +import { injectable } from 'inversify'; +import { + SimulationData, + SimulationInput, + SimulationRepository, +} from './SimulationRepository'; + +export const tenderlyRepositorySymbol = Symbol.for('TenderlyRepository'); + +@injectable() +export class SimulationRepositoryTenderly implements SimulationRepository { + async postBundleSimulation( + chainId: SupportedChainId, + simulationsInput: SimulationInput[] + ): Promise { + const simulations = simulationsInput.map((sim) => ({ + ...sim, + network_id: chainId.toString(), + gas_price: '0', + save: true, + save_if_fails: true, + })) as TenderlySimulatePayload[]; + const response = (await fetch( + `${TENDERLY_API_BASE_ENDPOINT}/simulate-bundle`, + { + method: 'POST', + body: JSON.stringify({ simulations }), + headers: { + 'X-Access-Key': TENDERLY_API_KEY, + }, + } + ).then((res) => res.json())) as + | TenderlyBundleSimulationResponse + | SimulationError; + + if (this.checkBundleSimulationError(response)) { + return null; + } + + // TODO: Add ERC20 state diffs + return response.simulation_results.map(({ simulation }) => ({ + status: simulation.status, + id: simulation.id, + link: getTenderlySimulationLink(simulation.id), + })); + } + + checkBundleSimulationError( + response: TenderlyBundleSimulationResponse | SimulationError + ): response is SimulationError { + return (response as SimulationError).error !== undefined; + } +} diff --git a/libs/repositories/src/SimulationRepository/SimulationrepositoryTenderly.test.ts b/libs/repositories/src/SimulationRepository/SimulationrepositoryTenderly.test.ts new file mode 100644 index 0000000..918035d --- /dev/null +++ b/libs/repositories/src/SimulationRepository/SimulationrepositoryTenderly.test.ts @@ -0,0 +1,83 @@ +import { Container } from 'inversify'; +import { SimulationRepositoryTenderly } from './SimulationRepositoryTenderly'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { WETH, NULL_ADDRESS } from '../../test/mock'; +import { + TENDERLY_API_KEY, + TENDERLY_ORG_NAME, + TENDERLY_PROJECT_NAME, +} from '../datasources/tenderlyApi'; + +// Transfering ETH from WETH to NULL ADDRESS +const TENDERLY_SIMULATION = { + from: WETH, + to: NULL_ADDRESS, + value: '1000000000000000000', + input: '0x', +}; + +const INVALID_TENDERLY_SIMULATION = { + from: NULL_ADDRESS, + to: WETH, + value: '0', + input: 'wrong input', +}; + +const FAILED_TENDERLY_SIMULATION = { + from: NULL_ADDRESS, + to: WETH, + value: '0', + input: + '0x23b872dd000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000000000000000000a', +}; + +describe('SimulationRepositoryTenderly', () => { + let tenderlyRepository: SimulationRepositoryTenderly; + + beforeAll(() => { + const container = new Container(); + container + .bind(SimulationRepositoryTenderly) + .to(SimulationRepositoryTenderly); + tenderlyRepository = container.get(SimulationRepositoryTenderly); + expect(TENDERLY_API_KEY).toBeDefined(); + expect(TENDERLY_ORG_NAME).toBeDefined(); + expect(TENDERLY_PROJECT_NAME).toBeDefined(); + }); + + describe('getTopTokenHolders', () => { + it('should return simulation data for success simulation', async () => { + const tenderlySimulationResult = + await tenderlyRepository.postBundleSimulation( + SupportedChainId.MAINNET, + [TENDERLY_SIMULATION] + ); + + expect(tenderlySimulationResult).toBeDefined(); + expect(tenderlySimulationResult?.length).toBe(1); + expect(tenderlySimulationResult?.[0].status).toBeTruthy(); + }, 100000); + + it('should return null for invalid simulation', async () => { + const tenderlySimulationResult = + await tenderlyRepository.postBundleSimulation( + SupportedChainId.MAINNET, + [INVALID_TENDERLY_SIMULATION] + ); + + expect(tenderlySimulationResult).toBeNull(); + }, 100000); + + it('should return simulation data for failed simulation', async () => { + const tenderlySimulationResult = + await tenderlyRepository.postBundleSimulation( + SupportedChainId.MAINNET, + [FAILED_TENDERLY_SIMULATION] + ); + + expect(tenderlySimulationResult).toBeDefined(); + expect(tenderlySimulationResult?.length).toBe(1); + expect(tenderlySimulationResult?.[0].status).toBeFalsy(); + }, 100000); + }); +}); diff --git a/libs/repositories/src/SimulationRepository/tenderlyTypes.ts b/libs/repositories/src/SimulationRepository/tenderlyTypes.ts new file mode 100644 index 0000000..70dab2a --- /dev/null +++ b/libs/repositories/src/SimulationRepository/tenderlyTypes.ts @@ -0,0 +1,574 @@ +export interface TenderlyBundleSimulationResponse { + simulation_results: TenderlySimulation[]; +} + +// types were found in Uniswap repository +// https://github.com/Uniswap/governance-seatbelt/blob/e2c6a0b11d1660f3bd934dab0d9df3ca6f90a1a0/types.d.ts#L123 + +type StateObject = { + balance?: string; + code?: string; + storage?: Record; +}; + +type ContractObject = { + contractName: string; + source: string; + sourcePath: string; + compiler: { + name: 'solc'; + version: string; + }; + networks: Record< + string, + { + events?: Record; + links?: Record; + address: string; + transactionHash?: string; + } + >; +}; + +export type TenderlySimulatePayload = { + network_id: string; + block_number?: number; + transaction_index?: number; + from: string; + to: string; + input: string; + gas: number; + gas_price?: string; + value?: string; + simulation_type?: 'full' | 'quick'; + save?: boolean; + save_if_fails?: boolean; + state_objects?: Record; + contracts?: ContractObject[]; + block_header?: { + number?: string; + timestamp?: string; + }; + generate_access_list?: boolean; +}; + +// --- Tenderly types, Response --- +// NOTE: These type definitions were autogenerated using https://app.quicktype.io/, so are almost +// certainly not entirely accurate (and they have some interesting type names) + +export interface TenderlySimulation { + transaction: Transaction; + simulation: Simulation; + contracts: TenderlyContract[]; + generated_access_list: GeneratedAccessList[]; +} + +interface TenderlyContract { + id: string; + contract_id: string; + balance: string; + network_id: string; + public: boolean; + export: boolean; + verified_by: string; + verification_date: null; + address: string; + contract_name: string; + ens_domain: null; + type: string; + evm_version: string; + compiler_version: string; + optimizations_used: boolean; + optimization_runs: number; + libraries: null; + data: Data; + creation_block: number; + creation_tx: string; + creator_address: string; + created_at: Date; + number_of_watches: null; + language: string; + in_project: boolean; + number_of_files: number; + standard?: string; + standards?: string[]; + token_data?: TokenData; +} + +interface Data { + main_contract: number; + contract_info: ContractInfo[]; + abi: ABI[]; + raw_abi: null; +} + +interface ABI { + type: ABIType; + name: string; + constant: boolean; + anonymous: boolean; + inputs: SoltypeElement[]; + outputs: Output[] | null; +} + +interface SoltypeElement { + name: string; + type: SoltypeType; + storage_location: StorageLocation; + components: SoltypeElement[] | null; + offset: number; + index: string; + indexed: boolean; + simple_type?: Type; +} + +interface Type { + type: SimpleTypeType; +} + +enum SimpleTypeType { + Address = 'address', + Bool = 'bool', + Bytes = 'bytes', + Slice = 'slice', + String = 'string', + Uint = 'uint', +} + +enum StorageLocation { + Calldata = 'calldata', + Default = 'default', + Memory = 'memory', + Storage = 'storage', +} + +enum SoltypeType { + Address = 'address', + Bool = 'bool', + Bytes32 = 'bytes32', + MappingAddressUint256 = 'mapping (address => uint256)', + MappingUint256Uint256 = 'mapping (uint256 => uint256)', + String = 'string', + Tuple = 'tuple', + TypeAddress = 'address[]', + TypeTuple = 'tuple[]', + Uint16 = 'uint16', + Uint256 = 'uint256', + Uint48 = 'uint48', + Uint56 = 'uint56', + Uint8 = 'uint8', +} + +interface Output { + name: string; + type: SoltypeType; + storage_location: StorageLocation; + components: SoltypeElement[] | null; + offset: number; + index: string; + indexed: boolean; + simple_type?: SimpleType; +} + +interface SimpleType { + type: SimpleTypeType; + nested_type?: Type; +} + +enum ABIType { + Constructor = 'constructor', + Event = 'event', + Function = 'function', +} + +interface ContractInfo { + id: number; + path: string; + name: string; + source: string; +} + +interface TokenData { + symbol: string; + name: string; + decimals: number; +} + +interface GeneratedAccessList { + address: string; + storage_keys: string[]; +} + +interface Simulation { + id: string; + project_id: string; + owner_id: string; + network_id: string; + block_number: number; + transaction_index: number; + from: string; + to: string; + input: string; + gas: number; + gas_price: string; + value: string; + method: string; + status: boolean; + access_list: null; + queue_origin: string; + created_at: Date; +} + +interface ErrorInfo { + error_message: string; + address: string; +} + +export interface SimulationError { + error: { + id: string; + message: string; + slug: string; + }; +} + +interface Transaction { + hash: string; + block_hash: string; + block_number: number; + from: string; + gas: number; + gas_price: number; + gas_fee_cap: number; + gas_tip_cap: number; + cumulative_gas_used: number; + gas_used: number; + effective_gas_price: number; + input: string; + nonce: number; + to: string; + index: number; + error_message?: string; + error_info?: ErrorInfo; + value: string; + access_list: null; + status: boolean; + addresses: string[]; + contract_ids: string[]; + network_id: string; + function_selector: string; + transaction_info: TransactionInfo; + timestamp: Date; + method: string; + decoded_input: null; + // Note: manually added (partial keys of `call_trace`) + call_trace: Array<{ + error?: string; + input: string; + }>; +} + +interface TransactionInfo { + contract_id: string; + block_number: number; + transaction_id: string; + contract_address: string; + method: string; + parameters: null; + intrinsic_gas: number; + refund_gas: number; + call_trace: CallTrace; + stack_trace: null | StackTrace[]; + logs: Log[] | null; + state_diff: StateDiff[]; + raw_state_diff: null; + console_logs: null; + created_at: Date; +} + +interface StackTrace { + file_index: number; + contract: string; + name: string; + line: number; + error: string; + error_reason: string; + code: string; + op: string; + length: number; +} + +interface CallTrace { + hash: string; + contract_name: string; + function_name: string; + function_pc: number; + function_op: string; + function_file_index: number; + function_code_start: number; + function_line_number: number; + function_code_length: number; + function_states: CallTraceFunctionState[]; + caller_pc: number; + caller_op: string; + call_type: string; + from: string; + from_balance: string; + to: string; + to_balance: string; + value: string; + caller: Caller; + block_timestamp: Date; + gas: number; + gas_used: number; + intrinsic_gas: number; + input: string; + decoded_input: Input[]; + state_diff: StateDiff[]; + logs: Log[]; + output: string; + decoded_output: FunctionVariableElement[]; + network_id: string; + calls: CallTraceCall[]; +} + +interface Caller { + address: string; + balance: string; +} + +interface CallTraceCall { + hash: string; + contract_name: string; + function_name: string; + function_pc: number; + function_op: string; + function_file_index: number; + function_code_start: number; + function_line_number: number; + function_code_length: number; + function_states: CallTraceFunctionState[]; + function_variables: FunctionVariableElement[]; + caller_pc: number; + caller_op: string; + caller_file_index: number; + caller_line_number: number; + caller_code_start: number; + caller_code_length: number; + call_type: string; + from: string; + from_balance: null; + to: string; + to_balance: null; + value: null; + caller: Caller; + block_timestamp: Date; + gas: number; + gas_used: number; + input: string; + decoded_input: Input[]; + output: string; + decoded_output: FunctionVariableElement[]; + network_id: string; + calls: PurpleCall[]; +} + +interface PurpleCall { + hash: string; + contract_name: string; + function_name: string; + function_pc: number; + function_op: string; + function_file_index: number; + function_code_start: number; + function_line_number: number; + function_code_length: number; + function_states?: FluffyFunctionState[]; + function_variables?: FunctionVariable[]; + caller_pc: number; + caller_op: string; + caller_file_index: number; + caller_line_number: number; + caller_code_start: number; + caller_code_length: number; + call_type: string; + from: string; + from_balance: null | string; + to: string; + to_balance: null | string; + value: null | string; + caller: Caller; + block_timestamp: Date; + gas: number; + gas_used: number; + refund_gas?: number; + input: string; + decoded_input: Input[]; + output: string; + decoded_output: FunctionVariable[] | null; + network_id: string; + calls: FluffyCall[] | null; +} + +interface FluffyCall { + hash: string; + contract_name: string; + function_name?: string; + function_pc: number; + function_op: string; + function_file_index?: number; + function_code_start?: number; + function_line_number?: number; + function_code_length?: number; + function_states?: FluffyFunctionState[]; + function_variables?: FunctionVariable[]; + caller_pc: number; + caller_op: string; + caller_file_index: number; + caller_line_number: number; + caller_code_start: number; + caller_code_length: number; + call_type: string; + from: string; + from_balance: null | string; + to: string; + to_balance: null | string; + value: null | string; + caller?: Caller; + block_timestamp: Date; + gas: number; + gas_used: number; + input: string; + decoded_input?: FunctionVariable[]; + output: string; + decoded_output: PurpleDecodedOutput[] | null; + network_id: string; + calls: TentacledCall[] | null; + refund_gas?: number; +} + +interface TentacledCall { + hash: string; + contract_name: string; + function_name: string; + function_pc: number; + function_op: string; + function_file_index: number; + function_code_start: number; + function_line_number: number; + function_code_length: number; + function_states: PurpleFunctionState[]; + caller_pc: number; + caller_op: string; + caller_file_index: number; + caller_line_number: number; + caller_code_start: number; + caller_code_length: number; + call_type: string; + from: string; + from_balance: null; + to: string; + to_balance: null; + value: null; + caller: Caller; + block_timestamp: Date; + gas: number; + gas_used: number; + input: string; + decoded_input: FunctionVariableElement[]; + output: string; + decoded_output: FunctionVariable[]; + network_id: string; + calls: null; +} + +interface FunctionVariableElement { + soltype: SoltypeElement; + value: string; +} + +interface FunctionVariable { + soltype: SoltypeElement; + value: PurpleValue | string; +} + +interface PurpleValue { + ballot: string; + basedOn: string; + configured: string; + currency: string; + cycleLimit: string; + discountRate: string; + duration: string; + fee: string; + id: string; + metadata: string; + number: string; + projectId: string; + start: string; + tapped: string; + target: string; + weight: string; +} + +interface PurpleFunctionState { + soltype: SoltypeElement; + value: Record; +} + +interface PurpleDecodedOutput { + soltype: SoltypeElement; + value: boolean | PurpleValue | string; +} + +interface FluffyFunctionState { + soltype: PurpleSoltype; + value: Record; +} + +interface PurpleSoltype { + name: string; + type: SoltypeType; + storage_location: StorageLocation; + components: null; + offset: number; + index: string; + indexed: boolean; +} + +interface Input { + soltype: SoltypeElement | null; + value: boolean | string; +} + +interface CallTraceFunctionState { + soltype: PurpleSoltype; + value: Record; +} + +interface Log { + name: string | null; + anonymous: boolean; + inputs: Input[]; + raw: LogRaw; +} + +interface LogRaw { + address: string; + topics: string[]; + data: string; +} + +interface StateDiff { + soltype: SoltypeElement | null; + original: string | Record; + dirty: string | Record; + raw: RawElement[]; +} + +interface RawElement { + address: string; + key: string; + original: string; + dirty: string; +} diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepository.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepository.ts new file mode 100644 index 0000000..aa57b82 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepository.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { SupportedChainId } from '@cowprotocol/shared'; + +export const tokenHolderRepositorySymbol = Symbol.for('TokenHolderRepository'); + +export interface TokenHolderPoint { + address: string; + balance: string; +} + +export interface TokenHolderRepository { + getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise; +} + +export class TokenHolderRepositoryNoop implements TokenHolderRepository { + async getTopTokenHolders( + _chainId: SupportedChainId, + _tokenAddress: string + ): Promise { + return null; + } +} diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.spec.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.spec.ts new file mode 100644 index 0000000..3792381 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.spec.ts @@ -0,0 +1,183 @@ +import { TokenHolderRepositoryCache } from './TokenHolderRepositoryCache'; +import IORedis from 'ioredis'; +import { TokenHolderRepository } from './TokenHolderRepository'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { NULL_ADDRESS, WETH } from '../../test/mock'; +import { CacheRepositoryRedis } from '../CacheRepository/CacheRepositoryRedis'; + +const CACHE_VALUE_SECONDS = 10; +const CACHE_NULL_SECONDS = 20; + +const wethLowercase = WETH.toLocaleLowerCase(); +const chainId = SupportedChainId.MAINNET; + +jest.mock('ioredis', () => { + return jest.fn().mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })); +}); + +describe('TokenHolderRepositoryCache', () => { + let tokenHoldersRepositoryCache: TokenHolderRepositoryCache; + let redisMock: jest.Mocked; + let proxyMock: jest.Mocked; + + beforeEach(() => { + redisMock = new IORedis() as jest.Mocked; + proxyMock = { + getTopTokenHolders: jest.fn(), + }; + const cacheRepository = new CacheRepositoryRedis(redisMock); + tokenHoldersRepositoryCache = new TokenHolderRepositoryCache( + proxyMock, + cacheRepository, + 'test-cache', + CACHE_VALUE_SECONDS, + CACHE_NULL_SECONDS + ); + }); + + const HOLDERS_1 = [ + { + address: NULL_ADDRESS, + balance: '1', + }, + ]; + + const HOLDERS_2 = [ + { + address: NULL_ADDRESS, + balance: '2', + }, + ]; + + const HOLDERS_1_STRING = JSON.stringify(HOLDERS_1); + const HOLDERS_2_STRING = JSON.stringify(HOLDERS_2); + + describe('getTopTokenHolders', () => { + it('should return token holders from cache', async () => { + // GIVEN: Cached value HOLDERS_1 + redisMock.get.mockResolvedValue(HOLDERS_1_STRING); + + // GIVEN: proxy returns HOLDERS_2 + proxyMock.getTopTokenHolders.mockResolvedValue(HOLDERS_2); + + // WHEN: Get Top Token Holders + const topTokenHolder = + await tokenHoldersRepositoryCache.getTopTokenHolders(chainId, WETH); + + expect(topTokenHolder).toStrictEqual(HOLDERS_1); + expect(proxyMock.getTopTokenHolders).not.toHaveBeenCalled(); + }); + + it('should return NULL from cache', async () => { + // GIVEN: Cached value 'null' + redisMock.get.mockResolvedValue('null'); + + // GIVEN: proxy returns HOLDERS_2 + proxyMock.getTopTokenHolders.mockResolvedValue(HOLDERS_2); + + // WHEN: Get Top Token Holders + const topTokenHolder = + await tokenHoldersRepositoryCache.getTopTokenHolders(chainId, WETH); + + // THEN: We get the cached value + expect(topTokenHolder).toEqual(null); + expect(proxyMock.getTopTokenHolders).not.toHaveBeenCalled(); + }); + + it('should call the proxy if no cache, then cache the value', async () => { + // GIVEN: The value is not cached + redisMock.get.mockResolvedValue(null); + + // GIVEN: proxy returns HOLDERS_2 + proxyMock.getTopTokenHolders.mockResolvedValue(HOLDERS_2); + + // WHEN: Get Top Token Holders + const topTokenHolder = + await tokenHoldersRepositoryCache.getTopTokenHolders(chainId, WETH); + + // THEN: The holders matches the result from the proxy + expect(topTokenHolder).toStrictEqual(HOLDERS_2); + + // THEN: The proxy has been called once + expect(proxyMock.getTopTokenHolders).toHaveBeenCalledWith(chainId, WETH); + + // THEN: The value returned by the proxy is cached + expect(redisMock.set).toHaveBeenCalledWith( + `repos:test-cache:usd-price:${chainId}:${wethLowercase}`, + HOLDERS_2_STRING, + 'EX', + CACHE_VALUE_SECONDS + ); + }); + + it('should call the proxy if no cache, then cache the NULL', async () => { + // GIVEN: The value is not cached + redisMock.get.mockResolvedValue(null); + + // GIVEN: proxy returns null + proxyMock.getTopTokenHolders.mockResolvedValue(null); + + // WHEN: Get Top Token Holders + const price = await tokenHoldersRepositoryCache.getTopTokenHolders( + chainId, + WETH + ); + + // THEN: The price matches the result from the proxy + expect(price).toEqual(null); + + // THEN: The proxy has been called once + expect(proxyMock.getTopTokenHolders).toHaveBeenCalledWith(chainId, WETH); + + // THEN: The value returned by the proxy is cached + expect(redisMock.set).toHaveBeenCalledWith( + `repos:test-cache:usd-price:${chainId}:${wethLowercase}`, + 'null', + 'EX', + CACHE_NULL_SECONDS + ); + }); + + it('should return the cached value, even if the proxy throws', async () => { + // GIVEN: Cached value HOLDERS_1_STRING + redisMock.get.mockResolvedValue(HOLDERS_1_STRING); + + // GIVEN: The proxy throws an awful error + proxyMock.getTopTokenHolders.mockImplementation(() => { + throw new Error('💥 Booom!'); + }); + + // WHEN: Get Top Token Holders + const tokenHolders = await tokenHoldersRepositoryCache.getTopTokenHolders( + chainId, + WETH + ); + + // THEN: The holders matches the result from the proxy + expect(tokenHolders).toStrictEqual(HOLDERS_1); + expect(proxyMock.getTopTokenHolders).not.toHaveBeenCalled(); + }); + + it('should throw if the proxy throws and there is no cache available', async () => { + // GIVEN: The value is not cached + redisMock.get.mockResolvedValue(null); + + // GIVEN: The proxy throws an awful error + proxyMock.getTopTokenHolders.mockImplementation(async () => { + throw new Error('💥 Booom!'); + }); + + // WHEN: Get Top Token Holders + const tokenHolderPromise = tokenHoldersRepositoryCache.getTopTokenHolders( + chainId, + WETH + ); + + // THEN: The call throws an awful error + expect(tokenHolderPromise).rejects.toThrow('💥 Booom!'); + }); + }); +}); diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.ts new file mode 100644 index 0000000..918d5dc --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryCache.ts @@ -0,0 +1,87 @@ +import { injectable } from 'inversify'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { CacheRepository } from '../CacheRepository/CacheRepository'; +import { getCacheKey, PartialCacheKey } from '../utils/cache'; +import { + TokenHolderPoint, + TokenHolderRepository, +} from './TokenHolderRepository'; + +const NULL_VALUE = 'null'; + +@injectable() +export class TokenHolderRepositoryCache implements TokenHolderRepository { + private baseCacheKey: PartialCacheKey[]; + + constructor( + private proxy: TokenHolderRepository, + private cache: CacheRepository, + private cacheName: string, + private cacheTimeValueSeconds: number, + private cacheTimeNullSeconds: number + ) { + this.baseCacheKey = ['repos', this.cacheName]; + } + + async getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + // Get price from cache + const key = getCacheKey( + ...this.baseCacheKey, + 'usd-price', + chainId, + tokenAddress + ); + const holdersCache = await this.getValueFromCache({ + key, + }); + + if (holdersCache !== undefined) { + return holdersCache; + } + + const tokenHolders = await this.proxy.getTopTokenHolders( + chainId, + tokenAddress + ); + + // Cache price (or absence of it) + this.cacheValue({ + key, + value: tokenHolders || null, + }); + + return tokenHolders; + } + + private async getValueFromCache(props: { + key: string; + }): Promise { + const { key } = props; + + const valueString = await this.cache.get(key); + if (valueString) { + return valueString === NULL_VALUE ? null : JSON.parse(valueString); + } + + return undefined; + } + + private async cacheValue(props: { + key: string; + value: TokenHolderPoint[] | null; + }): Promise { + const { key, value } = props; + + const cacheTimeSeconds = + value === null ? this.cacheTimeNullSeconds : this.cacheTimeValueSeconds; + + await this.cache.set( + key, + value === null ? NULL_VALUE : JSON.stringify(value), + cacheTimeSeconds + ); + } +} diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.test.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.test.ts new file mode 100644 index 0000000..71dd092 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.test.ts @@ -0,0 +1,67 @@ +import { Container } from 'inversify'; +import { TokenHolderRepositoryEthplorer } from './TokenHolderRepositoryEthplorer'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { WETH, NULL_ADDRESS } from '../../test/mock'; +import { ETHPLORER_API_KEY } from '../datasources/ethplorer'; + +describe('TokenHolderRepositoryEthplorer', () => { + let tokenHolderRepositoryEthplorer: TokenHolderRepositoryEthplorer; + + beforeAll(() => { + const container = new Container(); + container + .bind(TokenHolderRepositoryEthplorer) + .to(TokenHolderRepositoryEthplorer); + tokenHolderRepositoryEthplorer = container.get( + TokenHolderRepositoryEthplorer + ); + expect(ETHPLORER_API_KEY).toBeDefined(); + }); + + describe('getTopTokenHolders', () => { + it('should return the top token holders of WETH', async () => { + const tokenHolders = + await tokenHolderRepositoryEthplorer.getTopTokenHolders( + SupportedChainId.MAINNET, + WETH + ); + + expect(tokenHolders?.length).toBeGreaterThan(0); + expect(tokenHolders?.[0].address).toBeDefined(); + expect(Number(tokenHolders?.[0].balance)).toBeGreaterThan(0); + expect(Number(tokenHolders?.[0].balance)).toBeGreaterThan( + Number(tokenHolders?.[1].balance) + ); + }, 100000); + + it('should return null for an unknown token', async () => { + const tokenHolders = + await tokenHolderRepositoryEthplorer.getTopTokenHolders( + SupportedChainId.MAINNET, + NULL_ADDRESS + ); + + expect(tokenHolders).toBeNull(); + }, 100000); + + it('should return null for gnosis chain', async () => { + const tokenHolders = + await tokenHolderRepositoryEthplorer.getTopTokenHolders( + SupportedChainId.GNOSIS_CHAIN, + WETH + ); + + expect(tokenHolders).toBeNull(); + }, 100000); + + it('should return null for arbitrum one', async () => { + const tokenHolders = + await tokenHolderRepositoryEthplorer.getTopTokenHolders( + SupportedChainId.ARBITRUM_ONE, + WETH + ); + + expect(tokenHolders).toBeNull(); + }, 100000); + }); +}); diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.ts new file mode 100644 index 0000000..04ce649 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryEthplorer.ts @@ -0,0 +1,63 @@ +import { injectable } from 'inversify'; +import { + TokenHolderPoint, + TokenHolderRepository, +} from './TokenHolderRepository'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { + ETHPLORER_API_KEY, + ETHPLORER_BASE_URL, +} from '../datasources/ethplorer'; + +interface EthplorerSuccess { + holders: { + address: string; + balance: number; + share: number; + rawBalance: string; + }[]; +} + +interface EthplorerError { + error: { + message: string; + code: number; + }; +} + +@injectable() +export class TokenHolderRepositoryEthplorer implements TokenHolderRepository { + async getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + const baseAPI = ETHPLORER_BASE_URL[chainId]; + + if (!baseAPI) { + return null; + } + + const searchParams = new URLSearchParams({ + apiKey: ETHPLORER_API_KEY, + limit: '100', + }); + + const response = await fetch( + `${baseAPI}/getTopTokenHolders/${tokenAddress}?${searchParams}`, + { + method: 'GET', + } + ) + .then((res) => res.json() as Promise) + .catch((e) => e as EthplorerError); + + if ('error' in response || !response.holders.length) { + return null; + } + + return response.holders.map((item) => ({ + address: item.address, + balance: item.rawBalance, + })); + } +} diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.spec.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.spec.ts new file mode 100644 index 0000000..5611d06 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.spec.ts @@ -0,0 +1,116 @@ +import { SupportedChainId } from '@cowprotocol/shared'; +import { TokenHolderRepository } from './TokenHolderRepository'; +import { TokenHolderRepositoryFallback } from './TokenHolderRepositoryFallback'; +import { NULL_ADDRESS, WETH } from '../../test/mock'; + +const firstRepositoryResult = [ + { + address: NULL_ADDRESS, + balance: '1', + }, +]; + +const secondRepositoryResult = [ + { + address: NULL_ADDRESS, + balance: '2', + }, +]; + +class TokenHolderRepositoryMock_1 implements TokenHolderRepository { + async getTopTokenHolders() { + return firstRepositoryResult; + } +} + +class TokenHolderRepositoryMock_2 implements TokenHolderRepository { + async getTopTokenHolders() { + return secondRepositoryResult; + } +} + +class TokenHolderRepositoryMock_null implements TokenHolderRepository { + async getTopTokenHolders() { + return null; + } +} + +const PARAMS_PRICE = [SupportedChainId.MAINNET, WETH] as const; + +const tokenHoldersRepositoryMock_1 = new TokenHolderRepositoryMock_1(); +const tokenHoldersRepositoryMock_2 = new TokenHolderRepositoryMock_2(); +const tokenHoldersRepositoryMock_null = new TokenHolderRepositoryMock_null(); + +describe('TokenHolderRepositoryCoingecko', () => { + describe('getTopTokenHolders', () => { + it('Returns first repo price when is not null', async () => { + let tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback([ + tokenHoldersRepositoryMock_1, + tokenHoldersRepositoryMock_2, + ]); + + let tokenHolders = + await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + + expect(tokenHolders).toStrictEqual(firstRepositoryResult); + + tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback([ + tokenHoldersRepositoryMock_2, + tokenHoldersRepositoryMock_1, + ]); + + tokenHolders = await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + + expect(tokenHolders).toStrictEqual(secondRepositoryResult); + + tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback([ + tokenHoldersRepositoryMock_1, + tokenHoldersRepositoryMock_null, + ]); + + tokenHolders = await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + expect(tokenHolders).toStrictEqual(firstRepositoryResult); + }); + + it('Returns second repo holders when null', async () => { + const tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback([ + tokenHoldersRepositoryMock_null, + tokenHoldersRepositoryMock_1, + ]); + + const tokenHolders = + await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + expect(tokenHolders).toStrictEqual(firstRepositoryResult); + }); + + it('Returns null when configured with no repositories', async () => { + const tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback( + [] + ); + const tokenHolders = + await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + expect(tokenHolders).toEqual(null); + }); + + it('Returns null when no repo return holders', async () => { + const tokenHoldersRepositoryFallback = new TokenHolderRepositoryFallback([ + tokenHoldersRepositoryMock_null, + tokenHoldersRepositoryMock_null, + ]); + const price = await tokenHoldersRepositoryFallback.getTopTokenHolders( + ...PARAMS_PRICE + ); + expect(price).toEqual(null); + }); + }); +}); diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.ts new file mode 100644 index 0000000..9fa261c --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryFallback.ts @@ -0,0 +1,27 @@ +import { injectable } from 'inversify'; +import { + TokenHolderPoint, + TokenHolderRepository, +} from './TokenHolderRepository'; +import { SupportedChainId } from '@cowprotocol/shared'; + +@injectable() +export class TokenHolderRepositoryFallback implements TokenHolderRepository { + constructor(private tokenHolderRepositories: TokenHolderRepository[]) {} + + async getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + for (const tokenHolderRepository of this.tokenHolderRepositories) { + const tokenHolders = await tokenHolderRepository.getTopTokenHolders( + chainId, + tokenAddress + ); + if (tokenHolders !== null) { + return tokenHolders; + } + } + return null; + } +} diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.test.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.test.ts new file mode 100644 index 0000000..23b6096 --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.test.ts @@ -0,0 +1,47 @@ +import { Container } from 'inversify'; +import { TokenHolderRepositoryGoldRush } from './TokenHolderRepositoryGoldRush'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { WETH, NULL_ADDRESS } from '../../test/mock'; +import { GOLD_RUSH_API_KEY } from '../datasources/goldRush'; + +describe('TokenHolderRepositoryGoldRush', () => { + let tokenHolderRepositoryGoldRush: TokenHolderRepositoryGoldRush; + + beforeAll(() => { + const container = new Container(); + container + .bind(TokenHolderRepositoryGoldRush) + .to(TokenHolderRepositoryGoldRush); + tokenHolderRepositoryGoldRush = container.get( + TokenHolderRepositoryGoldRush + ); + expect(GOLD_RUSH_API_KEY).toBeDefined(); + }); + + describe('getTopTokenHolders', () => { + it('should return the top token holders of WETH', async () => { + const tokenHolders = + await tokenHolderRepositoryGoldRush.getTopTokenHolders( + SupportedChainId.MAINNET, + WETH + ); + + expect(tokenHolders?.length).toBeGreaterThan(0); + expect(tokenHolders?.[0].address).toBeDefined(); + expect(Number(tokenHolders?.[0].balance)).toBeGreaterThan(0); + expect(Number(tokenHolders?.[0].balance)).toBeGreaterThan( + Number(tokenHolders?.[1].balance) + ); + }, 100000); + + it('should return null for an unknown token', async () => { + const tokenHolders = + await tokenHolderRepositoryGoldRush.getTopTokenHolders( + SupportedChainId.MAINNET, + NULL_ADDRESS + ); + + expect(tokenHolders).toBeNull(); + }, 100000); + }); +}); diff --git a/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.ts b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.ts new file mode 100644 index 0000000..2e555db --- /dev/null +++ b/libs/repositories/src/TokenHolderRepository/TokenHolderRepositoryGoldRush.ts @@ -0,0 +1,67 @@ +import { injectable } from 'inversify'; +import { + TokenHolderPoint, + TokenHolderRepository, +} from './TokenHolderRepository'; +import { SupportedChainId } from '@cowprotocol/shared'; +import { + GOLD_RUSH_API_BASE_URL, + GOLD_RUSH_API_KEY, + GOLD_RUSH_CLIENT_NETWORK_MAPPING, +} from '../datasources/goldRush'; + +interface GoldRushTokenHolderItem { + contract_decimals: number; + contract_name: string; + contract_ticker_symbol: string; + contract_address: string; + supports_erc: string[]; + logo_url: string; + address: string; + balance: string; + total_supply: string; + block_height: number; +} + +interface GoldRushTokenHoldersResponse { + data: { + updated_at: string; + chain_id: number; + chain_name: string; + items: GoldRushTokenHolderItem[]; + pagination: { + has_more: boolean; + page_number: number; + page_size: number; + total_count: number; + }; + }; + error: boolean; + error_message: null | string; + error_code: null | number; +} + +@injectable() +export class TokenHolderRepositoryGoldRush implements TokenHolderRepository { + async getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + const response = (await fetch( + `${GOLD_RUSH_API_BASE_URL}/v1/${GOLD_RUSH_CLIENT_NETWORK_MAPPING[chainId]}/tokens/${tokenAddress}/token_holders_v2/`, + { + method: 'GET', + headers: { Authorization: `Bearer ${GOLD_RUSH_API_KEY}` }, + } + ).then((res) => res.json())) as GoldRushTokenHoldersResponse; + + if (response.error) { + return null; + } + + return response.data.items.map((item) => ({ + address: item.address, + balance: item.balance, + })); + } +} diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts index 6d7841a..6e1dc09 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCoingecko.test.ts @@ -1,7 +1,7 @@ import { Container } from 'inversify'; import { UsdRepositoryCoingecko } from './UsdRepositoryCoingecko'; import { SupportedChainId } from '@cowprotocol/shared'; -import { WETH, DEFINITELY_NOT_A_TOKEN } from '../../test/mock'; +import { WETH, NULL_ADDRESS } from '../../test/mock'; import ms from 'ms'; const FIVE_MINUTES = ms('5m'); @@ -33,7 +33,7 @@ describe('UsdRepositoryCoingecko', () => { it('should return NULL for an unknown token', async () => { const price = await usdRepositoryCoingecko.getUsdPrice( SupportedChainId.MAINNET, - DEFINITELY_NOT_A_TOKEN + NULL_ADDRESS ); // Price should be null (no data available) @@ -74,7 +74,7 @@ describe('UsdRepositoryCoingecko', () => { it('[5m] should return NULL for an unknown token', async () => { const prices = await usdRepositoryCoingecko.getUsdPrices( SupportedChainId.MAINNET, - DEFINITELY_NOT_A_TOKEN, + NULL_ADDRESS, '5m' ); diff --git a/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts index ae90e7b..8cf3675 100644 --- a/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts +++ b/libs/repositories/src/UsdRepository/UsdRepositoryCow.spec.ts @@ -1,12 +1,7 @@ import { SupportedChainId } from '@cowprotocol/shared'; import { UsdRepositoryCow } from './UsdRepositoryCow'; -import { - DEFINITELY_NOT_A_TOKEN, - WETH, - errorResponse, - okResponse, -} from '../../test/mock'; +import { NULL_ADDRESS, WETH, errorResponse, okResponse } from '../../test/mock'; import { USDC } from '../const'; import { Erc20Repository, Erc20 } from '../Erc20Repository/Erc20Repository'; import { CowApiClient } from '../datasources/cowApi'; @@ -109,7 +104,7 @@ describe('UsdRepositoryCow', () => { // 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 + NULL_ADDRESS // See https://api.cow.fi/mainnet/api/v1/token/0x0000000000000000000000000000000000000000/native_price ); // USD calculation based on native price is correct diff --git a/libs/repositories/src/datasources/ethplorer.ts b/libs/repositories/src/datasources/ethplorer.ts new file mode 100644 index 0000000..9b1dcf6 --- /dev/null +++ b/libs/repositories/src/datasources/ethplorer.ts @@ -0,0 +1,10 @@ +import { SupportedChainId } from '@cowprotocol/shared'; + +export const ETHPLORER_API_KEY = process.env.ETHPLORER_API_KEY as string; + +export const ETHPLORER_BASE_URL: Record = { + [SupportedChainId.MAINNET]: 'https://api.ethplorer.io', + [SupportedChainId.SEPOLIA]: 'https://sepolia-api.ethplorer.io', + [SupportedChainId.GNOSIS_CHAIN]: null, + [SupportedChainId.ARBITRUM_ONE]: null, +}; diff --git a/libs/repositories/src/datasources/goldRush.ts b/libs/repositories/src/datasources/goldRush.ts new file mode 100644 index 0000000..b6325da --- /dev/null +++ b/libs/repositories/src/datasources/goldRush.ts @@ -0,0 +1,14 @@ +import { SupportedChainId } from '@cowprotocol/shared'; + +export const GOLD_RUSH_API_KEY = process.env.GOLD_RUSH_API_KEY; +export const GOLD_RUSH_API_BASE_URL = 'https://api.covalenthq.com'; + +export const GOLD_RUSH_CLIENT_NETWORK_MAPPING: Record< + SupportedChainId, + string +> = { + [SupportedChainId.MAINNET]: 'eth-mainnet', + [SupportedChainId.SEPOLIA]: 'eth-sepolia', + [SupportedChainId.GNOSIS_CHAIN]: 'gnosis-mainnet', + [SupportedChainId.ARBITRUM_ONE]: 'arbitrum-mainnet', +}; diff --git a/libs/repositories/src/datasources/tenderlyApi.ts b/libs/repositories/src/datasources/tenderlyApi.ts new file mode 100644 index 0000000..837beb7 --- /dev/null +++ b/libs/repositories/src/datasources/tenderlyApi.ts @@ -0,0 +1,10 @@ +export const TENDERLY_API_KEY = process.env.TENDERLY_API_KEY as string; + +export const TENDERLY_ORG_NAME = process.env.TENDERLY_ORG_NAME; +export const TENDERLY_PROJECT_NAME = process.env.TENDERLY_PROJECT_NAME; + +export const TENDERLY_API_BASE_ENDPOINT = `https://api.tenderly.co/api/v1/account/${TENDERLY_ORG_NAME}/project/${TENDERLY_PROJECT_NAME}`; + +export const getTenderlySimulationLink = (simulationId: string): string => { + return `https://dashboard.tenderly.co/${TENDERLY_ORG_NAME}/${TENDERLY_PROJECT_NAME}/simulator/${simulationId}`; +}; diff --git a/libs/repositories/src/index.ts b/libs/repositories/src/index.ts index 15f3943..5b8ea66 100644 --- a/libs/repositories/src/index.ts +++ b/libs/repositories/src/index.ts @@ -27,3 +27,15 @@ export * from './UsdRepository/UsdRepositoryCoingecko'; export * from './UsdRepository/UsdRepositoryCow'; export * from './UsdRepository/UsdRepositoryFallback'; export * from './UsdRepository/UsdRepositoryCache'; + +// Token holder repositories +export * from './TokenHolderRepository/TokenHolderRepository'; +export * from './TokenHolderRepository/TokenHolderRepositoryGoldRush'; +export * from './TokenHolderRepository/TokenHolderRepositoryEthplorer'; +export * from './TokenHolderRepository/TokenHolderRepositoryCache'; +export * from './TokenHolderRepository/TokenHolderRepositoryFallback'; + +// Simulation repositories +export * from './SimulationRepository/SimulationRepository'; +export * from './SimulationRepository/SimulationRepositoryTenderly'; +export * from './SimulationRepository/tenderlyTypes'; diff --git a/libs/repositories/test/mock.ts b/libs/repositories/test/mock.ts index c97c179..27fd2ad 100644 --- a/libs/repositories/test/mock.ts +++ b/libs/repositories/test/mock.ts @@ -1,6 +1,5 @@ export const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; -export const DEFINITELY_NOT_A_TOKEN = - '0x0000000000000000000000000000000000000000'; +export const NULL_ADDRESS = '0x0000000000000000000000000000000000000000'; const MOCK_RESPONSE: Response = { status: 200, diff --git a/libs/services/src/SimulationService/SimulationService.ts b/libs/services/src/SimulationService/SimulationService.ts new file mode 100644 index 0000000..4ffc00a --- /dev/null +++ b/libs/services/src/SimulationService/SimulationService.ts @@ -0,0 +1,28 @@ +import { + SupportedChainId, + tenderlyRepositorySymbol, + SimulationRepository, + SimulationInput, + SimulationData, +} from '@cowprotocol/repositories'; +import { injectable, inject } from 'inversify'; + +export const simulationServiceSymbol = Symbol.for('SimulationServiceSymbol'); + +@injectable() +export class SimulationService { + constructor( + @inject(tenderlyRepositorySymbol) + private simulationRepository: SimulationRepository + ) {} + + async postTenderlyBundleSimulation( + chainId: SupportedChainId, + simulationInput: SimulationInput[] + ): Promise { + return this.simulationRepository.postBundleSimulation( + chainId, + simulationInput + ); + } +} diff --git a/libs/services/src/TokenHolderService/TokenHolderService.ts b/libs/services/src/TokenHolderService/TokenHolderService.ts new file mode 100644 index 0000000..bd9423c --- /dev/null +++ b/libs/services/src/TokenHolderService/TokenHolderService.ts @@ -0,0 +1,31 @@ +import { + TokenHolderRepository, + tokenHolderRepositorySymbol, + SupportedChainId, + TokenHolderPoint, +} from '@cowprotocol/repositories'; +import { injectable, inject } from 'inversify'; + +export interface TokenHolderService { + getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise; +} + +export const tokenHolderServiceSymbol = Symbol.for('TokenHolderService'); + +@injectable() +export class TokenHolderServiceMain implements TokenHolderService { + constructor( + @inject(tokenHolderRepositorySymbol) + private tokenHolderRepository: TokenHolderRepository + ) {} + + async getTopTokenHolders( + chainId: SupportedChainId, + tokenAddress: string + ): Promise { + return this.tokenHolderRepository.getTopTokenHolders(chainId, tokenAddress); + } +} diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 55b5d08..b33a27c 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -3,3 +3,7 @@ export * from './SlippageService/SlippageServiceMain'; export * from './SlippageService/SlippageServiceMock'; export * from './UsdService/UsdService'; + +export * from './TokenHolderService/TokenHolderService'; + +export * from './SimulationService/SimulationService';