From fe23ca8d75604dd792f38b0e9a2496cc54e5a922 Mon Sep 17 00:00:00 2001 From: bogdan-rosianu <51945539+bogdan-rosianu@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:49:39 +0200 Subject: [PATCH] Development to main (#1561) * Enhance error handling in GatewayProxyController for block retrieval by hash (#1545) * use EsdtType instead of hardcoded values (#1544) * token service improvements (#1543) * processes 50 tokens concurrently using ConcurrencyUtils * run all three batch operations in parallel with increased concurrency * add more logs * apply mex prices * add logs ( to be removed ) * increse cron time expression * set cron to 2 minutes * Websocket subscriptions (#1528) * add websockets for blocks and txs * add support for subscribe to stats * use websockets rooms * remove logs * add lock on crons * check stats room exists Signed-off-by: GuticaStefan * fix indent spaces * add validation pipes + filters * add try catch + class validator fixes * fix linter * add pool subscription + reduce filters combinations for subscriptions * lint * add support for events subscription * lint * separate subscription websocket into separate app * add config * add path * fix * add path for events + config default settings * temp logs * temp logs 2 * added missing configs + remove temp logs * enable andromeda in config * add metrics on subscription * remove async + reschedule * refresh metrics every second * set max listeners to 12 * add EOL * add count on update + parallel broadcast to rooms * lower ttl for blocks count cache * remove comments * remove comments * renaming --------- Signed-off-by: GuticaStefan Co-authored-by: bogdan-rosianu Co-authored-by: cfaur09 * log instead of error for invalid legacy delegation contract (#1542) * implement event logAddress filter (#1548) * implement event logAddress filter * implement topics filter * Token market cap updates (#1550) * token mcap update * add logs * extend logs * add support for timestampMs for account * fix unit tests * improve token fetch price (#1549) * improve token fetch price * remove logger * update spec * fix lint error * add logs for error * add price * Enhance NFT type support in cache warmer and collection services (#1552) * Refactor function filter application in ElasticIndexerHelper to enhance query conditions with AND operator and existence checks (#1554) * apply supply info for all tokens (#1558) * Refactor token market cap calculation to ensure price and circulating supply are checked before computation * add specs * fix lint * added reserved field to blocks (#1560) * add support for custom url custom headers (#1557) * Add custom URL headers support and enhance token data fetching * Enhance custom URL headers handling by adding JSON parsing and validation checks * add logs * fixes * remove app.hatom.com value * add EOL * fix indentation --------- Signed-off-by: GuticaStefan Co-authored-by: Catalin Faur <52102171+cfaur09@users.noreply.github.com> Co-authored-by: Gutica Stefan <123564494+stefangutica@users.noreply.github.com> Co-authored-by: cfaur09 Co-authored-by: GuticaStefan --- config/config.mainnet.yaml | 4 + src/common/api-config/api.config.service.ts | 63 +++++++++++++++- .../indexer/elastic/elastic.indexer.helper.ts | 18 ++++- src/common/indexer/entities/account.ts | 1 + .../cache.warmer/cache.warmer.service.ts | 2 + src/endpoints/accounts/account.service.ts | 3 + src/endpoints/accounts/entities/account.ts | 5 +- src/endpoints/blocks/entities/block.ts | 3 + .../collections/collection.service.ts | 2 +- .../events/entities/events.filter.ts | 2 + src/endpoints/events/events.controller.ts | 12 ++- src/endpoints/tokens/token.service.ts | 40 +++++++--- src/test/unit/services/accounts.spec.ts | 1 + src/test/unit/services/blocks.spec.ts | 1 + src/test/unit/services/events.spec.ts | 44 +++++++++++ src/test/unit/services/tokens.spec.ts | 75 +++++++++---------- 16 files changed, 220 insertions(+), 56 deletions(-) diff --git a/config/config.mainnet.yaml b/config/config.mainnet.yaml index 9e9bc7250..2ab0a01a5 100644 --- a/config/config.mainnet.yaml +++ b/config/config.mainnet.yaml @@ -191,3 +191,7 @@ compression: level: 6 threshold: 1024 chunkSize: 16384 +customUrlHeaders: + - urlPattern: '' + headers: + x-custom-auth: '' diff --git a/src/common/api-config/api.config.service.ts b/src/common/api-config/api.config.service.ts index e157c943a..d1cd162f2 100644 --- a/src/common/api-config/api.config.service.ts +++ b/src/common/api-config/api.config.service.ts @@ -1,4 +1,4 @@ -import { Constants } from '@multiversx/sdk-nestjs-common'; +import { Constants, OriginLogger } from '@multiversx/sdk-nestjs-common'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DatabaseConnectionOptions } from '../persistence/entities/connection.options'; @@ -7,6 +7,8 @@ import { LogTopic } from '@multiversx/sdk-transaction-processor/lib/types/log-to @Injectable() export class ApiConfigService { + private readonly logger = new OriginLogger(ApiConfigService.name); + constructor(private readonly configService: ConfigService) { } @@ -972,4 +974,63 @@ export class ApiConfigService { return port; } + + getHeadersForCustomUrl(url: string): Record | undefined { + let customUrlConfigs = this.configService.get('customUrlHeaders'); + + if (!customUrlConfigs) { + return undefined; + } + + if (typeof customUrlConfigs === 'string') { + try { + customUrlConfigs = JSON.parse(customUrlConfigs); + } catch (error) { + return undefined; + } + } + + if (!Array.isArray(customUrlConfigs) && typeof customUrlConfigs === 'object') { + let workingConfig = customUrlConfigs; + + while (workingConfig && workingConfig[''] && typeof workingConfig[''] === 'object') { + workingConfig = workingConfig['']; + } + + const arrayValues: any[] = []; + for (const key in workingConfig) { + if (!isNaN(Number(key))) { + let item = workingConfig[key]; + while (item && item[''] && typeof item[''] === 'object') { + item = item['']; + } + arrayValues[Number(key)] = item; + } + } + + if (arrayValues.length > 0) { + customUrlConfigs = arrayValues.filter(item => item !== undefined); + this.logger.log(`Loaded ${customUrlConfigs.length} custom URL header config(s)`); + } else { + return undefined; + } + } + + if (!Array.isArray(customUrlConfigs)) { + return undefined; + } + + for (const config of customUrlConfigs) { + if (config && config.urlPattern && url.includes(config.urlPattern)) { + let headers = config.headers; + if (headers && headers[''] && typeof headers[''] === 'object') { + headers = headers['']; + } + this.logger.log(`Found custom headers for URL pattern '${config.urlPattern}': ${JSON.stringify(headers)}`); + return headers; + } + } + + return undefined; + } } diff --git a/src/common/indexer/elastic/elastic.indexer.helper.ts b/src/common/indexer/elastic/elastic.indexer.helper.ts index 75c6d21ed..96bd3ec13 100644 --- a/src/common/indexer/elastic/elastic.indexer.helper.ts +++ b/src/common/indexer/elastic/elastic.indexer.helper.ts @@ -777,8 +777,12 @@ export class ElasticIndexerHelper { public applyFunctionFilter(elasticQuery: ElasticQuery, functions: string[]) { const functionConditions = []; for (const field of functions) { - functionConditions.push(QueryType.Match('function', field)); - functionConditions.push(QueryType.Match('operation', field)); + functionConditions.push(QueryType.Match('function', field, QueryOperator.AND)); + + functionConditions.push(QueryType.Must( + [QueryType.Match('operation', field, QueryOperator.AND)], + [QueryType.Exists('function')] + )); } return elasticQuery.withMustCondition(QueryType.Should(functionConditions)); } @@ -814,6 +818,16 @@ export class ElasticIndexerHelper { elasticQuery = elasticQuery.withCondition(QueryConditionOptions.must, QueryType.Match('order', filter.order)); } + if (filter.logAddress) { + elasticQuery = elasticQuery.withMustMatchCondition('logAddress', filter.logAddress); + } + + if (filter.topics && filter.topics.length > 0) { + for (const topic of filter.topics) { + elasticQuery = elasticQuery.withMustMatchCondition('topics', topic); + } + } + return elasticQuery; } } diff --git a/src/common/indexer/entities/account.ts b/src/common/indexer/entities/account.ts index 94963eb91..fc5ff3296 100644 --- a/src/common/indexer/entities/account.ts +++ b/src/common/indexer/entities/account.ts @@ -1,6 +1,7 @@ export interface Account { address: string; nonce: number; + timestampMs: number; timestamp: number; balance: string; balanceNum: number; diff --git a/src/crons/cache.warmer/cache.warmer.service.ts b/src/crons/cache.warmer/cache.warmer.service.ts index f4cd9bdf2..e50c8b391 100644 --- a/src/crons/cache.warmer/cache.warmer.service.ts +++ b/src/crons/cache.warmer/cache.warmer.service.ts @@ -324,9 +324,11 @@ export class CacheWarmerService { const nftTypes = [ NftType.NonFungibleESDT, NftType.SemiFungibleESDT, + NftType.MetaESDT, NftType.NonFungibleESDTv2, NftType.DynamicNonFungibleESDT, NftType.DynamicSemiFungibleESDT, + NftType.DynamicMetaESDT, ]; for (const key of Object.keys(allAssets)) { diff --git a/src/endpoints/accounts/account.service.ts b/src/endpoints/accounts/account.service.ts index cde2c8564..104a1b5be 100644 --- a/src/endpoints/accounts/account.service.ts +++ b/src/endpoints/accounts/account.service.ts @@ -99,6 +99,9 @@ export class AccountService { if (options?.withTimestamp) { const elasticSearchAccount = await this.indexerService.getAccount(address); account.timestamp = elasticSearchAccount.timestamp; + if (elasticSearchAccount.timestampMs) { + account.timestampMs = elasticSearchAccount.timestampMs; + } } if (AddressUtils.isSmartContractAddress(address)) { diff --git a/src/endpoints/accounts/entities/account.ts b/src/endpoints/accounts/entities/account.ts index 6403d3cce..14df44e8a 100644 --- a/src/endpoints/accounts/entities/account.ts +++ b/src/endpoints/accounts/entities/account.ts @@ -16,7 +16,10 @@ export class Account { @ApiProperty({ type: Number, description: 'Account current nonce', example: 42 }) nonce: number = 0; - @ApiProperty({ type: Number, description: 'Timestamp of the block where the account was first indexed', example: 1676979360 }) + @ApiProperty({ type: Number, description: 'Timestamp in milliseconds of the block where the account was first indexed', example: 1676979360000 }) + timestampMs: number = 0; + + @ApiProperty({ type: Number, description: 'Timestamp in seconds of the block where the account was first indexed', example: 1676979360 }) timestamp: number = 0; @ApiProperty({ type: Number, description: 'The shard ID allocated to the account', example: 0 }) diff --git a/src/endpoints/blocks/entities/block.ts b/src/endpoints/blocks/entities/block.ts index ed5dc51e7..aa6d63076 100644 --- a/src/endpoints/blocks/entities/block.ts +++ b/src/endpoints/blocks/entities/block.ts @@ -68,6 +68,9 @@ export class Block { @ApiProperty({ type: BlockProofDto, nullable: true, required: false }) previousHeaderProof: BlockProofDto | undefined = undefined; + @ApiProperty( { type: String }) + reserved: string = ''; + @ApiProperty({ type: BlockProofDto, nullable: true, required: false }) proof: BlockProofDto | undefined = undefined; diff --git a/src/endpoints/collections/collection.service.ts b/src/endpoints/collections/collection.service.ts index 4c9b803f9..ce8378053 100644 --- a/src/endpoints/collections/collection.service.ts +++ b/src/endpoints/collections/collection.service.ts @@ -99,7 +99,7 @@ export class CollectionService { nftCollection.subType = indexedCollection.type as NftSubType; nftCollection.timestamp = indexedCollection.timestamp; - if (nftCollection.type.in(NftType.NonFungibleESDT, NftType.SemiFungibleESDT)) { + if (nftCollection.type.in(NftType.NonFungibleESDT, NftType.SemiFungibleESDT, NftType.MetaESDT)) { nftCollection.isVerified = indexedCollection.api_isVerified; nftCollection.nftCount = indexedCollection.api_nftCount; nftCollection.holderCount = indexedCollection.api_holderCount; diff --git a/src/endpoints/events/entities/events.filter.ts b/src/endpoints/events/entities/events.filter.ts index 099a1ec45..2852a14d5 100644 --- a/src/endpoints/events/entities/events.filter.ts +++ b/src/endpoints/events/entities/events.filter.ts @@ -11,4 +11,6 @@ export class EventsFilter { before: number = 0; after: number = 0; order: number = 0; + logAddress: string = ''; + topics: string[] = []; } diff --git a/src/endpoints/events/events.controller.ts b/src/endpoints/events/events.controller.ts index ee18ec489..5c5fc9f08 100644 --- a/src/endpoints/events/events.controller.ts +++ b/src/endpoints/events/events.controller.ts @@ -20,26 +20,31 @@ export class EventsController { @ApiQuery({ name: 'from', description: 'Number of items to skip for the result set', required: false }) @ApiQuery({ name: 'size', description: 'Number of items to retrieve', required: false }) @ApiQuery({ name: 'address', description: 'Event address', required: false }) + @ApiQuery({ name: 'logAddress', description: 'Event log address', required: false }) @ApiQuery({ name: 'identifier', description: 'Event identifier', required: false }) @ApiQuery({ name: 'txHash', description: 'Event transaction hash', required: false }) @ApiQuery({ name: 'shard', description: 'Event shard id', required: false }) @ApiQuery({ name: 'before', description: 'Event before timestamp', required: false }) @ApiQuery({ name: 'after', description: 'Event after timestamp', required: false }) @ApiQuery({ name: 'order', description: 'Event order', required: false }) + @ApiQuery({ name: 'topics', description: 'Event topics to filter by', required: false, isArray: true }) async getEvents( @Query('from', new DefaultValuePipe(0), ParseIntPipe) from: number, @Query('size', new DefaultValuePipe(25), ParseIntPipe) size: number, @Query('address', ParseAddressPipe) address: string, + @Query('logAddress', ParseAddressPipe) logAddress: string, @Query('identifier') identifier: string, @Query('txHash') txHash: string, @Query('shard', ParseIntPipe) shard: number, @Query('before', ParseIntPipe) before: number, @Query('after', ParseIntPipe) after: number, @Query('order', ParseIntPipe) order: number, + @Query('topics') topics: string | string[], ): Promise { + const topicsArray = topics ? (Array.isArray(topics) ? topics : [topics]) : []; return await this.eventsService.getEvents( new QueryPagination({ from, size }), - new EventsFilter({ address, identifier, txHash, shard, after, before, order })); + new EventsFilter({ address, logAddress, identifier, txHash, shard, after, before, order, topics: topicsArray })); } @Get('/events/count') @@ -51,6 +56,7 @@ export class EventsController { @ApiQuery({ name: 'shard', description: 'Event shard id', required: false }) @ApiQuery({ name: 'before', description: 'Event before timestamp', required: false }) @ApiQuery({ name: 'after', description: 'Event after timestamp', required: false }) + @ApiQuery({ name: 'topics', description: 'Event topics to filter by', required: false, isArray: true }) async getEventsCount( @Query('address', ParseAddressPipe) address: string, @Query('identifier') identifier: string, @@ -58,9 +64,11 @@ export class EventsController { @Query('shard', ParseIntPipe) shard: number, @Query('before', ParseIntPipe) before: number, @Query('after', ParseIntPipe) after: number, + @Query('topics') topics: string | string[], ): Promise { + const topicsArray = topics ? (Array.isArray(topics) ? topics : [topics]) : []; return await this.eventsService.getEventsCount( - new EventsFilter({ address, identifier, txHash, shard, after, before })); + new EventsFilter({ address, identifier, txHash, shard, after, before, topics: topicsArray })); } @Get('/events/:txHash') diff --git a/src/endpoints/tokens/token.service.ts b/src/endpoints/tokens/token.service.ts index 3de4fe3f9..e4672b6d1 100644 --- a/src/endpoints/tokens/token.service.ts +++ b/src/endpoints/tokens/token.service.ts @@ -50,6 +50,7 @@ export class TokenService { private readonly logger = new OriginLogger(TokenService.name); private readonly nftSubTypes = [NftSubType.DynamicNonFungibleESDT, NftSubType.DynamicMetaESDT, NftSubType.NonFungibleESDTv2, NftSubType.DynamicSemiFungibleESDT]; private readonly egldIdentifierInMultiTransfer = 'EGLD-000000'; + private readonly thresholdFaultyMarketCap = 10_000_000_000; constructor( private readonly esdtService: EsdtService, @@ -831,20 +832,35 @@ export class TokenService { token.price = await this.dataApiService.getEsdtTokenPrice(token.identifier); } else if (priceSourcetype === TokenAssetsPriceSourceType.customUrl && token.assets?.priceSource?.url) { const pathToPrice = token.assets?.priceSource?.path ?? "0.usdPrice"; - const tokenData = await this.fetchTokenDataFromUrl(token.assets.priceSource.url, pathToPrice); + const customHeaders = this.apiConfigService.getHeadersForCustomUrl(token.assets.priceSource.url); + const tokenData = await this.fetchTokenDataFromUrl(token.assets.priceSource.url, pathToPrice, customHeaders); if (tokenData) { token.price = tokenData; } } + if (!token.price && token.type === TokenType.FungibleESDT) { + try { + const dataApiPrice = await this.dataApiService.getEsdtTokenPrice(token.identifier); + if (dataApiPrice) { + token.price = dataApiPrice; + this.logger.log(`Applied dataAPI fallback for ${token.identifier} token with price ${dataApiPrice}`); + } + } catch (error) { + this.logger.error(`Error applying dataAPI fallback price for token ${token.identifier}: ${error}`); + } + } - if (token.price) { - const supply = await this.esdtService.getTokenSupply(token.identifier); - token.supply = supply.totalSupply; - token.circulatingSupply = supply.circulatingSupply; + const supply = await this.esdtService.getTokenSupply(token.identifier); + token.supply = supply.totalSupply; + token.circulatingSupply = supply.circulatingSupply; - if (token.circulatingSupply) { - token.marketCap = token.price * NumberUtils.denominateString(token.circulatingSupply, token.decimals); + if (token.price && token.circulatingSupply) { + token.marketCap = token.price * NumberUtils.denominateString(token.circulatingSupply, token.decimals); + // TODO: update this by checking the token's liquidity collateral + if (token.marketCap > this.thresholdFaultyMarketCap) { + this.logger.log(`Setting token market cap to 0 due to possibly faulty market cap. Token: ${token.identifier}. Circulating supply: ${token.circulatingSupply}. Price: ${token.price}. Market cap: ${token.marketCap}`); + token.marketCap = 0; } } } catch (error) { @@ -896,9 +912,11 @@ export class TokenService { return result; } - private async fetchTokenDataFromUrl(url: string, path: string): Promise { + private async fetchTokenDataFromUrl(url: string, path: string, customHeaders?: Record): Promise { try { - const result = await this.apiService.get(url); + + this.logger.log(`Fetching token data from URL: ${url} with custom headers: ${JSON.stringify(customHeaders)}`); + const result = await this.apiService.get(url, customHeaders ? { headers: customHeaders } : undefined); if (!result || !result.data) { this.logger.error(`Invalid response received from URL: ${url}`); @@ -1087,6 +1105,10 @@ export class TokenService { if (price.isToken) { token.price = price.price; token.marketCap = price.price * NumberUtils.denominateString(supply.circulatingSupply, token.decimals); + if (token.marketCap > this.thresholdFaultyMarketCap) { + this.logger.log(`Setting token market cap to 0 due to possibly faulty market cap. Token: ${token.identifier}. Circulating supply: ${supply.circulatingSupply}. Price: ${token.price}. Market cap: ${token.marketCap}`); + token.marketCap = 0; + } if (token.totalLiquidity && token.marketCap && (token.totalLiquidity / token.marketCap < LOW_LIQUIDITY_THRESHOLD)) { token.isLowLiquidity = true; diff --git a/src/test/unit/services/accounts.spec.ts b/src/test/unit/services/accounts.spec.ts index 7a7933537..96ae23554 100644 --- a/src/test/unit/services/accounts.spec.ts +++ b/src/test/unit/services/accounts.spec.ts @@ -283,6 +283,7 @@ describe('Account Service', () => { address: 'erd1qga7ze0l03chfgru0a32wxqf2226nzrxnyhzer9lmudqhjgy7ycqjjyknz', balance: '162486906126924046', nonce: 45, + timestampMs: 0, timestamp: 0, shard: 0, ownerAddress: '', diff --git a/src/test/unit/services/blocks.spec.ts b/src/test/unit/services/blocks.spec.ts index 5684f66ce..45f364dd6 100644 --- a/src/test/unit/services/blocks.spec.ts +++ b/src/test/unit/services/blocks.spec.ts @@ -136,6 +136,7 @@ describe('Block Service', () => { gasRefunded: 4932000, gasPenalized: 0, maxGasLimit: 15000000000, + reserved: '', scheduledRootHash: undefined, proof: undefined, previousHeaderProof: new BlockProofDto({ diff --git a/src/test/unit/services/events.spec.ts b/src/test/unit/services/events.spec.ts index d8251fbcf..4af1100ac 100644 --- a/src/test/unit/services/events.spec.ts +++ b/src/test/unit/services/events.spec.ts @@ -137,6 +137,50 @@ describe('EventsService', () => { expect(result).toEqual(expectedEvents); expect(indexerService.getEvents).toHaveBeenCalledWith(pagination, filter); }); + + it('should return events filtered by log address', async () => { + const pagination: QueryPagination = { from: 0, size: 10 }; + const filter: EventsFilter = new EventsFilter({ logAddress: "erd1qqqqqqqqqqqqqpgq5lgsm8lsen2gv65gwtrs25js0ktx7ltgusrqeltmln" }); + + const mockElasticEvents = [ + generateMockEvent(), + ]; + + const expectedEvents = [ + createExpectedEvent("7e3faa2a4ea5cfe8667f2e13eb27076b0452742dbe01044871c8ea109f73ebed", "transferValueOnly"), + ]; + + mockIndexerService.getEvents.mockResolvedValue(mockElasticEvents); + + const result = await service.getEvents(pagination, filter); + + for (const event of result) { + expect(event.logAddress).toEqual("erd1qqqqqqqqqqqqqpgq5lgsm8lsen2gv65gwtrs25js0ktx7ltgusrqeltmln"); + } + + expect(result).toEqual(expectedEvents); + expect(indexerService.getEvents).toHaveBeenCalledWith(pagination, filter); + }); + + it('should return events filtered by topics', async () => { + const pagination: QueryPagination = { from: 0, size: 10 }; + const filter: EventsFilter = new EventsFilter({ topics: ["2386f26fc10000"] }); + + const mockElasticEvents = [ + generateMockEvent(), + ]; + + const expectedEvents = [ + createExpectedEvent("7e3faa2a4ea5cfe8667f2e13eb27076b0452742dbe01044871c8ea109f73ebed", "transferValueOnly"), + ]; + + mockIndexerService.getEvents.mockResolvedValue(mockElasticEvents); + + const result = await service.getEvents(pagination, filter); + + expect(result).toEqual(expectedEvents); + expect(indexerService.getEvents).toHaveBeenCalledWith(pagination, filter); + }); }); describe('getEventsCount', () => { diff --git a/src/test/unit/services/tokens.spec.ts b/src/test/unit/services/tokens.spec.ts index 94d1a6ba6..36cb6e424 100644 --- a/src/test/unit/services/tokens.spec.ts +++ b/src/test/unit/services/tokens.spec.ts @@ -30,7 +30,6 @@ import { Token } from "src/endpoints/tokens/entities/token"; import { NftCollection } from "src/endpoints/collections/entities/nft.collection"; import { EsdtSupply } from "src/endpoints/esdt/entities/esdt.supply"; import { TokenDetailed } from "src/endpoints/tokens/entities/token.detailed"; -import { NumberUtils } from "@multiversx/sdk-nestjs-common"; import { TokenAssetsPriceSourceType } from "../../../common/assets/entities/token.assets.price.source.type"; describe('Token Service', () => { @@ -694,7 +693,7 @@ describe('Token Service', () => { it('should return tokens from other sources when isTokensFetchFeatureEnabled is false', async () => { const mockTokenProperties: Partial[] = [{ identifier: 'mockIdentifier' }]; - let mockTokens: Partial[] = mockTokenProperties.map(properties => ApiUtils.mergeObjects(new TokenDetailed(), properties)); + const mockTokens: Partial[] = mockTokenProperties.map(properties => ApiUtils.mergeObjects(new TokenDetailed(), properties)); const mockTokenAssets: Partial = { name: 'mockName' }; const mockNftCollections: Partial[] = [{ collection: 'mockCollection' }]; const mockTokenSupply: Partial = { totalSupply: '1000000000000000000', circulatingSupply: '500000000000000000' }; @@ -711,7 +710,7 @@ describe('Token Service', () => { jest.spyOn(tokenService as any, 'applyMexPairType').mockImplementation(() => Promise.resolve()); jest.spyOn(tokenService as any, 'applyMexPairTradesCount').mockImplementation(() => Promise.resolve()); jest.spyOn(cacheService as any, 'batchApplyAll').mockImplementation(() => Promise.resolve()); - jest.spyOn(dataApiService, 'getEsdtTokenPrice').mockResolvedValue(100); + jest.spyOn(dataApiService, 'getEsdtTokenPrice').mockResolvedValue(undefined); jest.spyOn(dataApiService, 'getEgldPrice').mockResolvedValue(100); jest.spyOn(tokenService as any, 'fetchTokenDataFromUrl').mockResolvedValue(100); jest.spyOn(esdtService, 'getTokenSupply').mockResolvedValue(mockTokenSupply as EsdtSupply); @@ -754,13 +753,19 @@ describe('Token Service', () => { })); }); - expect((tokenService as any).batchProcessTokens).toHaveBeenCalledWith(mockTokens); - expect((tokenService as any).applyMexLiquidity).toHaveBeenCalledWith(mockTokens.filter(x => x.type !== TokenType.MetaESDT)); - expect((tokenService as any).applyMexPrices).toHaveBeenCalledWith(mockTokens.filter(x => x.type !== TokenType.MetaESDT)); - expect((tokenService as any).applyMexPairType).toHaveBeenCalledWith(mockTokens.filter(x => x.type !== TokenType.MetaESDT)); - expect((tokenService as any).applyMexPairTradesCount).toHaveBeenCalledWith(mockTokens.filter(x => x.type !== TokenType.MetaESDT)); + expect((tokenService as any).batchProcessTokens).toHaveBeenCalledTimes(1); + const batchProcessCall = ((tokenService as any).batchProcessTokens as jest.Mock).mock.calls[0][0]; + expect(batchProcessCall).toHaveLength(2); + expect(batchProcessCall.map((t: any) => t.identifier)).toContain('mockIdentifier'); + expect(batchProcessCall.map((t: any) => t.identifier)).toContain('mockCollection'); + + expect((tokenService as any).applyMexLiquidity).toHaveBeenCalledTimes(1); + expect((tokenService as any).applyMexPrices).toHaveBeenCalledTimes(1); + expect((tokenService as any).applyMexPairType).toHaveBeenCalledTimes(1); + expect((tokenService as any).applyMexPairTradesCount).toHaveBeenCalledTimes(1); expect((cacheService as any).batchApplyAll).toHaveBeenCalled(); - mockTokens.forEach(mockToken => { + + for (const mockToken of mockTokens) { const priceSourcetype = mockToken.assets?.priceSource?.type; if (priceSourcetype === 'dataApi') { expect(dataApiService.getEsdtTokenPrice).toHaveBeenCalledWith(mockToken.identifier); @@ -769,40 +774,28 @@ describe('Token Service', () => { expect((tokenService as any).fetchTokenDataFromUrl).toHaveBeenCalledWith(mockToken.assets?.priceSource?.url, pathToPrice); } - if (mockToken.price) { - expect(esdtService.getTokenSupply).toHaveBeenCalledWith(mockToken.identifier); - mockToken.supply = mockTokenSupply.totalSupply; + expect(esdtService.getTokenSupply).toHaveBeenCalledWith(mockToken.identifier); + } - if (mockToken.circulatingSupply) { - mockToken.marketCap = mockToken.price * NumberUtils.denominateString(mockToken.circulatingSupply.toString(), mockToken.decimals); - } - } + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(3); + + const nonEgldTokens = result.filter(t => t.identifier !== 'EGLD-000000'); + nonEgldTokens.forEach(token => { + expect(token.supply).toBe(mockTokenSupply.totalSupply); + expect(token.circulatingSupply).toBe(mockTokenSupply.circulatingSupply); }); - mockTokens = mockTokens.sortedDescending( - token => token.assets ? 1 : 0, - token => token.isLowLiquidity ? 0 : (token.marketCap ?? 0), - token => token.transactions ?? 0, - ); - - mockTokens.push(new TokenDetailed({ - identifier: 'EGLD-000000', - name: 'EGLD', - canPause: false, - canUpgrade: false, - canWipe: false, - price: 100, - decimals: 18, - isLowLiquidity: false, - marketCap: 0, - circulatingSupply: '0', - supply: '0', - assets: { - name: 'mockName', - } as TokenAssets, - })); + expect(result.map(t => t.identifier)).toContain('mockIdentifier'); + expect(result.map(t => t.identifier)).toContain('mockCollection'); + expect(result.map(t => t.identifier)).toContain('EGLD-000000'); - expect(result).toEqual(mockTokens); + const egldToken = result.find(t => t.identifier === 'EGLD-000000'); + expect(egldToken).toBeDefined(); + expect(egldToken?.price).toBe(100); + expect(egldToken?.supply).toBe('0'); + expect(egldToken?.circulatingSupply).toBe('0'); }); }); @@ -841,7 +834,9 @@ describe('Token Service', () => { jest.spyOn(tokenService['collectionService'], 'getNftCollections').mockResolvedValue([]); jest.spyOn(tokenService['dataApiService'], 'getEgldPrice').mockResolvedValue(0); - jest.spyOn(tokenService['dataApiService'], 'getEsdtTokenPrice').mockResolvedValue(1); + jest.spyOn(tokenService['dataApiService'], 'getEsdtTokenPrice').mockImplementation((identifier: string) => { + return Promise.resolve(identifier === 'token4' ? undefined : 1); + }); jest.spyOn(tokenService['esdtService'], 'getTokenSupply').mockResolvedValue({ minted: '1000000', initialMinted: '1000000',