From f72b482fbb4faaa5c50b7b8838a18fd2d8678be0 Mon Sep 17 00:00:00 2001 From: bogdan-rosianu Date: Fri, 27 Jun 2025 11:38:12 +0300 Subject: [PATCH 1/5] use events index instead of logs --- .../elastic/elastic.indexer.service.ts | 4 +-- src/common/indexer/entities/index.ts | 2 +- .../indexer/entities/transaction.log.ts | 12 ++++++++ src/common/indexer/indexer.interface.ts | 4 +-- src/common/indexer/indexer.service.ts | 4 +-- .../entities/transaction.log.event.ts | 3 ++ .../transactions/transaction.get.service.ts | 29 ++++++++++++++++++- 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/common/indexer/elastic/elastic.indexer.service.ts b/src/common/indexer/elastic/elastic.indexer.service.ts index ad06426aa..4d931abb9 100644 --- a/src/common/indexer/elastic/elastic.indexer.service.ts +++ b/src/common/indexer/elastic/elastic.indexer.service.ts @@ -594,14 +594,14 @@ export class ElasticIndexerService implements IndexerInterface { async getTransactionLogs(hashes: string[]): Promise { const queries = []; for (const hash of hashes) { - queries.push(QueryType.Match('_id', hash)); + queries.push(QueryType.Match('txHash', hash)); } const elasticQueryLogs = ElasticQuery.create() .withPagination({ from: 0, size: 10000 }) .withCondition(QueryConditionOptions.should, queries); - return await this.elasticService.getList('logs', 'id', elasticQueryLogs); + return await this.elasticService.getList('events', 'id', elasticQueryLogs); } async getTransactionScResults(txHash: string): Promise { diff --git a/src/common/indexer/entities/index.ts b/src/common/indexer/entities/index.ts index 5d665fff3..2cc7597c3 100644 --- a/src/common/indexer/entities/index.ts +++ b/src/common/indexer/entities/index.ts @@ -12,5 +12,5 @@ export { Tag } from './tag'; export { Token } from './token'; export { TokenAccount, TokenType } from './token.account'; export { Transaction } from './transaction'; -export { TransactionLog, TransactionLogEvent } from './transaction.log'; +export { TransactionLog, TransactionLogEvent, ElasticTransactionLogEvent } from './transaction.log'; export { TransactionReceipt } from './transaction.receipt'; diff --git a/src/common/indexer/entities/transaction.log.ts b/src/common/indexer/entities/transaction.log.ts index a9a3c1d6d..3a71225a3 100644 --- a/src/common/indexer/entities/transaction.log.ts +++ b/src/common/indexer/entities/transaction.log.ts @@ -13,3 +13,15 @@ export interface TransactionLogEvent { data?: string; order: number; } + +export interface ElasticTransactionLogEvent { + address: string; + identifier: string; + topics: string[]; + data?: string; + order: number; + txHash: string; + originalTxHash: string; + logAddress: string; + additionalData?: string[]; +} diff --git a/src/common/indexer/indexer.interface.ts b/src/common/indexer/indexer.interface.ts index 4a65b4657..45ace16e8 100644 --- a/src/common/indexer/indexer.interface.ts +++ b/src/common/indexer/indexer.interface.ts @@ -12,7 +12,7 @@ import { TokenWithRolesFilter } from "src/endpoints/tokens/entities/token.with.r import { TransactionFilter } from "src/endpoints/transactions/entities/transaction.filter"; import { TokenAssets } from "../assets/entities/token.assets"; import { QueryPagination } from "../entities/query.pagination"; -import { Account, AccountHistory, AccountTokenHistory, Block, Collection, MiniBlock, Operation, Round, ScDeploy, ScResult, Tag, Token, TokenAccount, Transaction, TransactionLog, TransactionReceipt } from "./entities"; +import { Account, AccountHistory, AccountTokenHistory, Block, Collection, MiniBlock, Operation, Round, ScDeploy, ScResult, Tag, Token, TokenAccount, Transaction, ElasticTransactionLogEvent, TransactionReceipt } from "./entities"; import { AccountAssets } from "../assets/entities/account.assets"; import { ProviderDelegators } from "./entities/provider.delegators"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; @@ -134,7 +134,7 @@ export interface IndexerInterface { getTokensForAddress(address: string, queryPagination: QueryPagination, filter: TokenFilter): Promise - getTransactionLogs(hashes: string[]): Promise + getTransactionLogs(hashes: string[]): Promise getTransactionScResults(txHash: string): Promise diff --git a/src/common/indexer/indexer.service.ts b/src/common/indexer/indexer.service.ts index 9fcbb7236..24b997184 100644 --- a/src/common/indexer/indexer.service.ts +++ b/src/common/indexer/indexer.service.ts @@ -11,7 +11,7 @@ import { TransactionFilter } from "src/endpoints/transactions/entities/transacti import { MetricsEvents } from "src/utils/metrics-events.constants"; import { TokenAssets } from "../assets/entities/token.assets"; import { QueryPagination } from "../entities/query.pagination"; -import { Account, AccountHistory, AccountTokenHistory, Block, Collection, MiniBlock, Operation, Round, ScDeploy, ScResult, Tag, Token, TokenAccount, Transaction, TransactionLog, TransactionReceipt } from "./entities"; +import { Account, AccountHistory, AccountTokenHistory, Block, Collection, MiniBlock, Operation, Round, ScDeploy, ScResult, Tag, Token, TokenAccount, Transaction, ElasticTransactionLogEvent, TransactionReceipt } from "./entities"; import { IndexerInterface } from "./indexer.interface"; import { LogPerformanceAsync } from "src/utils/log.performance.decorator"; import { AccountQueryOptions } from "src/endpoints/accounts/entities/account.query.options"; @@ -302,7 +302,7 @@ export class IndexerService implements IndexerInterface { } @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) - async getTransactionLogs(hashes: string[]): Promise { + async getTransactionLogs(hashes: string[]): Promise { return await this.indexerInterface.getTransactionLogs(hashes); } diff --git a/src/endpoints/transactions/entities/transaction.log.event.ts b/src/endpoints/transactions/entities/transaction.log.event.ts index 34a588f66..e4a68680a 100644 --- a/src/endpoints/transactions/entities/transaction.log.event.ts +++ b/src/endpoints/transactions/entities/transaction.log.event.ts @@ -21,6 +21,9 @@ export class TransactionLogEvent { @ApiProperty() data: string = ''; + @ApiProperty() + order: number = 0; + @ApiProperty() additionalData: string[] | undefined = undefined; } diff --git a/src/endpoints/transactions/transaction.get.service.ts b/src/endpoints/transactions/transaction.get.service.ts index 1d098539d..c1a78e28f 100644 --- a/src/endpoints/transactions/transaction.get.service.ts +++ b/src/endpoints/transactions/transaction.get.service.ts @@ -52,7 +52,34 @@ export class TransactionGetService { } private async getTransactionLogsFromElasticInternal(hashes: string[]): Promise { - return await this.indexerService.getTransactionLogs(hashes); + const rawHits = await this.indexerService.getTransactionLogs(hashes); + + const logsMap: Map = new Map(); + + for (const source of rawHits) { + const txHash = source.txHash; + + if (!logsMap.has(txHash)) { + logsMap.set(txHash, new TransactionLog({ + id: txHash, + address: source.address, + events: [], + })); + } + + const event = { + identifier: source.identifier, + address: source.logAddress || source.address, + data: BinaryUtils.hexToBase64(source.data ?? ''), + additionalData: source.additionalData?.map(d => BinaryUtils.hexToBase64(d)), + topics: source.topics?.map(t => BinaryUtils.hexToBase64(t)), + order: source.order ?? 0, + }; + + logsMap.get(txHash)?.events.push(ApiUtils.mergeObjects(new TransactionLogEvent(), event)); + } + + return Array.from(logsMap.values()); } async getTransactionScResultsFromElastic(txHash: string): Promise { From 254d0f8a4e62451063d22ebb8a8fe495c328184a Mon Sep 17 00:00:00 2001 From: bogdan-rosianu Date: Fri, 27 Jun 2025 14:33:31 +0300 Subject: [PATCH 2/5] configurable migrated indices --- config/config.devnet.yaml | 2 ++ config/config.e2e.mainnet.yaml | 2 ++ config/config.mainnet.yaml | 2 ++ config/config.testnet.yaml | 2 ++ src/common/api-config/api.config.service.ts | 4 ++++ .../elastic/elastic.indexer.service.ts | 6 +++--- src/common/indexer/indexer.interface.ts | 2 +- src/common/indexer/indexer.service.ts | 4 ++-- .../transactions/transaction.get.service.ts | 20 +++++++++++++++++-- 9 files changed, 36 insertions(+), 8 deletions(-) diff --git a/config/config.devnet.yaml b/config/config.devnet.yaml index e8b7aab1c..94a330119 100644 --- a/config/config.devnet.yaml +++ b/config/config.devnet.yaml @@ -84,6 +84,8 @@ features: durationThresholdMs: 5000 failureCountThreshold: 5 resetTimeoutMs: 30000 + elasticMigratedIndices: + logs: 'events' statusChecker: enabled: false thresholds: diff --git a/config/config.e2e.mainnet.yaml b/config/config.e2e.mainnet.yaml index 01cfb53ec..b4c431ec0 100644 --- a/config/config.e2e.mainnet.yaml +++ b/config/config.e2e.mainnet.yaml @@ -85,6 +85,8 @@ features: durationThresholdMs: 5000 failureCountThreshold: 5 resetTimeoutMs: 30000 + elasticMigratedIndices: + logs: 'events' statusChecker: enabled: false thresholds: diff --git a/config/config.mainnet.yaml b/config/config.mainnet.yaml index 1120e41f2..edc9c7a00 100644 --- a/config/config.mainnet.yaml +++ b/config/config.mainnet.yaml @@ -85,6 +85,8 @@ features: durationThresholdMs: 5000 failureCountThreshold: 5 resetTimeoutMs: 30000 + elasticMigratedIndices: + logs: 'events' statusChecker: enabled: false thresholds: diff --git a/config/config.testnet.yaml b/config/config.testnet.yaml index 90f59e456..1f1887414 100644 --- a/config/config.testnet.yaml +++ b/config/config.testnet.yaml @@ -84,6 +84,8 @@ features: durationThresholdMs: 5000 failureCountThreshold: 5 resetTimeoutMs: 30000 + elasticMigratedIndices: + logs: 'events' statusChecker: enabled: false thresholds: diff --git a/src/common/api-config/api.config.service.ts b/src/common/api-config/api.config.service.ts index b7bfd6ed3..77c400332 100644 --- a/src/common/api-config/api.config.service.ts +++ b/src/common/api-config/api.config.service.ts @@ -407,6 +407,10 @@ export class ApiConfigService { }; } + getElasticMigratedIndicesConfig(): Record { + return this.configService.get>('features.elasticMigratedIndices') ?? {}; + } + getIsWebsocketApiActive(): boolean { return this.configService.get('api.websocket') ?? true; } diff --git a/src/common/indexer/elastic/elastic.indexer.service.ts b/src/common/indexer/elastic/elastic.indexer.service.ts index 4d931abb9..fc7438ff7 100644 --- a/src/common/indexer/elastic/elastic.indexer.service.ts +++ b/src/common/indexer/elastic/elastic.indexer.service.ts @@ -591,17 +591,17 @@ export class ElasticIndexerService implements IndexerInterface { return query; } - async getTransactionLogs(hashes: string[]): Promise { + async getTransactionLogs(hashes: string[], eventsIndex: string, txHashField: string): Promise { const queries = []; for (const hash of hashes) { - queries.push(QueryType.Match('txHash', hash)); + queries.push(QueryType.Match(txHashField, hash)); } const elasticQueryLogs = ElasticQuery.create() .withPagination({ from: 0, size: 10000 }) .withCondition(QueryConditionOptions.should, queries); - return await this.elasticService.getList('events', 'id', elasticQueryLogs); + return await this.elasticService.getList(eventsIndex, 'id', elasticQueryLogs); } async getTransactionScResults(txHash: string): Promise { diff --git a/src/common/indexer/indexer.interface.ts b/src/common/indexer/indexer.interface.ts index 45ace16e8..8ede69e50 100644 --- a/src/common/indexer/indexer.interface.ts +++ b/src/common/indexer/indexer.interface.ts @@ -134,7 +134,7 @@ export interface IndexerInterface { getTokensForAddress(address: string, queryPagination: QueryPagination, filter: TokenFilter): Promise - getTransactionLogs(hashes: string[]): Promise + getTransactionLogs(hashes: string[], eventsIndex: string, txHashField: string): Promise getTransactionScResults(txHash: string): Promise diff --git a/src/common/indexer/indexer.service.ts b/src/common/indexer/indexer.service.ts index 24b997184..3798f604e 100644 --- a/src/common/indexer/indexer.service.ts +++ b/src/common/indexer/indexer.service.ts @@ -302,8 +302,8 @@ export class IndexerService implements IndexerInterface { } @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) - async getTransactionLogs(hashes: string[]): Promise { - return await this.indexerInterface.getTransactionLogs(hashes); + async getTransactionLogs(hashes: string[], eventsIndex: string, txHashField: string): Promise { + return await this.indexerInterface.getTransactionLogs(hashes, eventsIndex, txHashField); } @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) diff --git a/src/endpoints/transactions/transaction.get.service.ts b/src/endpoints/transactions/transaction.get.service.ts index c1a78e28f..dfbc6ba73 100644 --- a/src/endpoints/transactions/transaction.get.service.ts +++ b/src/endpoints/transactions/transaction.get.service.ts @@ -20,6 +20,7 @@ import { TransactionOperationType } from "./entities/transaction.operation.type" import { QueryPagination } from "src/common/entities/query.pagination"; import { NftFilter } from "../nfts/entities/nft.filter"; import { TokenAccount } from "src/common/indexer/entities"; +import { ApiConfigService } from "../../common/api-config/api.config.service"; @Injectable() export class TransactionGetService { @@ -30,6 +31,7 @@ export class TransactionGetService { private readonly gatewayService: GatewayService, @Inject(forwardRef(() => TokenTransferService)) private readonly tokenTransferService: TokenTransferService, + private readonly apiConfigService: ApiConfigService, ) { } private async tryGetTransactionFromElasticBySenderAndNonce(sender: string, nonce: number): Promise { @@ -51,8 +53,22 @@ export class TransactionGetService { return result.map(x => ApiUtils.mergeObjects(new TransactionLog(), x)); } - private async getTransactionLogsFromElasticInternal(hashes: string[]): Promise { - const rawHits = await this.indexerService.getTransactionLogs(hashes); + private async getTransactionLogsFromElasticInternal(hashes: string[]) { + const esMigratedIndices = this.apiConfigService.getElasticMigratedIndicesConfig(); + const index = esMigratedIndices?.['logs'] ?? 'logs'; + if (index === 'events') { + return await this.getTransactionLogsFromElasticInternalEventsIndex(hashes); + } + + return await this.getTransactionLogsFromElasticInternalLogsIndex(hashes); + } + + private async getTransactionLogsFromElasticInternalLogsIndex(hashes: string[]): Promise { + return await this.indexerService.getTransactionLogs(hashes, 'logs', '_id'); + } + + private async getTransactionLogsFromElasticInternalEventsIndex(hashes: string[]): Promise { + const rawHits = await this.indexerService.getTransactionLogs(hashes, 'events', 'txHash'); const logsMap: Map = new Map(); From 8f0b97d45d7c70d88d01276e943b36e8eb7bedb2 Mon Sep 17 00:00:00 2001 From: bogdan-rosianu Date: Fri, 27 Jun 2025 17:04:10 +0300 Subject: [PATCH 3/5] added protection for empty fields --- src/endpoints/transactions/transaction.get.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/endpoints/transactions/transaction.get.service.ts b/src/endpoints/transactions/transaction.get.service.ts index dfbc6ba73..b33f0bfe5 100644 --- a/src/endpoints/transactions/transaction.get.service.ts +++ b/src/endpoints/transactions/transaction.get.service.ts @@ -86,9 +86,9 @@ export class TransactionGetService { const event = { identifier: source.identifier, address: source.logAddress || source.address, - data: BinaryUtils.hexToBase64(source.data ?? ''), - additionalData: source.additionalData?.map(d => BinaryUtils.hexToBase64(d)), - topics: source.topics?.map(t => BinaryUtils.hexToBase64(t)), + data: source.data && source.data.length > 0 ? BinaryUtils.hexToBase64(source.data ?? '') : source.data, + additionalData: source.additionalData?.map(d => d && d.length > 0 ? BinaryUtils.hexToBase64(d) : d), + topics: source.topics?.map(t => t && t.length > 0 ? BinaryUtils.hexToBase64(t) : t), order: source.order ?? 0, }; From 0a4cbfb55aa7a273834b11da6291a031b3556150 Mon Sep 17 00:00:00 2001 From: cfaur09 Date: Wed, 30 Jul 2025 17:12:45 +0300 Subject: [PATCH 4/5] fix log address --- src/endpoints/transactions/transaction.get.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/endpoints/transactions/transaction.get.service.ts b/src/endpoints/transactions/transaction.get.service.ts index b33f0bfe5..ec3e97a1d 100644 --- a/src/endpoints/transactions/transaction.get.service.ts +++ b/src/endpoints/transactions/transaction.get.service.ts @@ -78,14 +78,14 @@ export class TransactionGetService { if (!logsMap.has(txHash)) { logsMap.set(txHash, new TransactionLog({ id: txHash, - address: source.address, + address: source.logAddress, events: [], })); } const event = { identifier: source.identifier, - address: source.logAddress || source.address, + address: source.address, data: source.data && source.data.length > 0 ? BinaryUtils.hexToBase64(source.data ?? '') : source.data, additionalData: source.additionalData?.map(d => d && d.length > 0 ? BinaryUtils.hexToBase64(d) : d), topics: source.topics?.map(t => t && t.length > 0 ? BinaryUtils.hexToBase64(t) : t), From 22ab7e4cd767774d774915c44bb1d5207f34ae86 Mon Sep 17 00:00:00 2001 From: Catalin Faur <52102171+cfaur09@users.noreply.github.com> Date: Thu, 31 Jul 2025 13:14:50 +0300 Subject: [PATCH 5/5] add transaction get unit tests (#1525) --- .../unit/services/transaction.get.spec.ts | 734 ++++++++++++++++++ 1 file changed, 734 insertions(+) create mode 100644 src/test/unit/services/transaction.get.spec.ts diff --git a/src/test/unit/services/transaction.get.spec.ts b/src/test/unit/services/transaction.get.spec.ts new file mode 100644 index 000000000..d6990b54f --- /dev/null +++ b/src/test/unit/services/transaction.get.spec.ts @@ -0,0 +1,734 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BinaryUtils } from '@multiversx/sdk-nestjs-common'; +import { ApiUtils } from '@multiversx/sdk-nestjs-http'; +import { TransactionGetService } from '../../../endpoints/transactions/transaction.get.service'; +import { IndexerService } from '../../../common/indexer/indexer.service'; +import { GatewayService } from '../../../common/gateway/gateway.service'; +import { TokenTransferService } from '../../../endpoints/tokens/token.transfer.service'; +import { ApiConfigService } from '../../../common/api-config/api.config.service'; +import { TransactionLog } from '../../../endpoints/transactions/entities/transaction.log'; +import { TransactionLogEvent } from '../../../endpoints/transactions/entities/transaction.log.event'; +import { TransactionDetailed } from '../../../endpoints/transactions/entities/transaction.detailed'; +import { TransactionOptionalFieldOption } from '../../../endpoints/transactions/entities/transaction.optional.field.options'; +import { MiniBlockType } from '../../../endpoints/miniblocks/entities/mini.block.type'; +import { TransactionStatus } from '../../../endpoints/transactions/entities/transaction.status'; + +describe('TransactionGetService', () => { + let service: TransactionGetService; + let indexerService: jest.Mocked; + let gatewayService: jest.Mocked; + let tokenTransferService: jest.Mocked; + let apiConfigService: jest.Mocked; + + const mockTransactionHash = 'abc123def456'; + const mockSender = 'erd1qga7ze0l03chfgru0a32wxqf2226nzrxnyhzer9lmudqhjgy7ycqjjyknz'; + const mockReceiver = 'erd15hmuycqw4mkaksfp0yu0auy548urd0wp6wyd4vtjkg3t6h9he5ystm2sv6'; + + const createMockTransaction = (overrides?: any) => ({ + txHash: mockTransactionHash, + hash: mockTransactionHash, + miniBlockHash: 'mb123', + nonce: 1, + round: 12345, + epoch: 500, + value: '1000000000000000000', + receiver: mockReceiver, + sender: mockSender, + gasPrice: 1000000000, + gasLimit: 50000, + gasUsed: 25000, + data: '', + signature: 'sig123', + sourceShard: 0, + destinationShard: 1, + blockNonce: 12345, + blockHash: 'block123', + notarizedAtSourceInMetaNonce: 12340, + NotarizedAtSourceInMetaHash: 'meta123', + notarizedAtDestinationInMetaNonce: 12341, + notarizedAtDestinationInMetaHash: 'meta124', + miniblockType: 'TxBlock', + miniblockHash: 'mb123', + status: 'success', + hyperblockNonce: 12300, + hyperblockHash: 'hyper123', + timestamp: 1634567890, + searchOrder: 1, + hasScResults: false, + hasOperations: false, + tokens: [], + esdtValues: [], + operation: 'transfer', + function: '', + isRelayed: false, + ...overrides, + }); + + const createMockGatewayTransaction = (overrides?: any) => ({ + type: 'Transaction', + processingTypeOnSource: 'Normal', + processingTypeOnDestination: 'Normal', + hash: mockTransactionHash, + nonce: 1, + value: '1000000000000000000', + receiver: mockReceiver, + sender: mockSender, + gasPrice: 1000000000, + gasLimit: 50000, + gasUsed: 25000, + data: '', + signature: 'sig123', + sourceShard: 0, + destinationShard: 1, + blockNonce: 12345, + blockHash: 'block123', + miniblockHash: 'mb123', + status: 'success', + round: 12345, + fee: 100000000000000, + timestamp: 1634567890, + miniblockType: MiniBlockType.TxBlock, + receipt: undefined, + smartContractResults: undefined, + logs: undefined, + guardian: undefined, + guardianSignature: undefined, + relayerAddress: undefined, + relayerSignature: undefined, + receiverUsername: undefined, + senderUsername: undefined, + ...overrides, + }); + + beforeEach(async () => { + const indexerServiceMock = { + getTransactionBySenderAndNonce: jest.fn(), + getTransactionLogs: jest.fn(), + getTransaction: jest.fn(), + getTransactionScResults: jest.fn(), + getTransactionReceipts: jest.fn(), + getNfts: jest.fn(), + }; + + const gatewayServiceMock = { + getTransaction: jest.fn(), + }; + + const tokenTransferServiceMock = { + getOperationsForTransaction: jest.fn(), + }; + + const apiConfigServiceMock = { + getElasticMigratedIndicesConfig: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionGetService, + { provide: IndexerService, useValue: indexerServiceMock }, + { provide: GatewayService, useValue: gatewayServiceMock }, + { provide: TokenTransferService, useValue: tokenTransferServiceMock }, + { provide: ApiConfigService, useValue: apiConfigServiceMock }, + ], + }).compile(); + + service = module.get(TransactionGetService); + indexerService = module.get(IndexerService); + gatewayService = module.get(GatewayService); + tokenTransferService = module.get(TokenTransferService); + apiConfigService = module.get(ApiConfigService); + + jest.spyOn(BinaryUtils, 'hexToBase64').mockImplementation((hex: string) => { + if (!hex || hex.length === 0) return hex; + return Buffer.from(hex, 'hex').toString('base64'); + }); + + jest.spyOn(BinaryUtils, 'base64Encode').mockImplementation((str: string) => { + return Buffer.from(str).toString('base64'); + }); + + jest.spyOn(BinaryUtils, 'numberToHex').mockImplementation((num: number) => { + return num.toString(16); + }); + + jest.spyOn(ApiUtils, 'mergeObjects').mockImplementation((target: any, source: any) => { + return { ...target, ...source }; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getTransactionLogsFromElastic', () => { + it('should handle small batch of hashes', async () => { + const hashes = ['hash1', 'hash2']; + const expectedLogs = [ + new TransactionLog({ id: 'hash1', address: 'addr1', events: [] }), + new TransactionLog({ id: 'hash2', address: 'addr2', events: [] }), + ]; + + jest.spyOn(service as any, 'getTransactionLogsFromElasticInternal') + .mockResolvedValue(expectedLogs); + + const result = await service.getTransactionLogsFromElastic(hashes); + + expect(result).toHaveLength(2); + expect(service['getTransactionLogsFromElasticInternal']).toHaveBeenCalledWith(hashes); + }); + + it('should handle large batch of hashes (>1000)', async () => { + const hashes = Array.from({ length: 1500 }, (_, i) => `hash${i}`); + const firstBatch = Array.from({ length: 1000 }, (_, i) => `hash${i}`); + const secondBatch = Array.from({ length: 500 }, (_, i) => `hash${i + 1000}`); + + const firstBatchLogs = firstBatch.map(hash => new TransactionLog({ id: hash, address: 'addr', events: [] })); + const secondBatchLogs = secondBatch.map(hash => new TransactionLog({ id: hash, address: 'addr', events: [] })); + + jest.spyOn(service as any, 'getTransactionLogsFromElasticInternal') + .mockResolvedValueOnce(firstBatchLogs) + .mockResolvedValueOnce(secondBatchLogs); + + const result = await service.getTransactionLogsFromElastic(hashes); + + expect(result).toHaveLength(1500); + expect(service['getTransactionLogsFromElasticInternal']).toHaveBeenCalledTimes(2); + expect(service['getTransactionLogsFromElasticInternal']).toHaveBeenNthCalledWith(1, firstBatch); + expect(service['getTransactionLogsFromElasticInternal']).toHaveBeenNthCalledWith(2, secondBatch); + }); + + it('should return empty array for empty hashes', async () => { + const result = await service.getTransactionLogsFromElastic([]); + expect(result).toEqual([]); + }); + }); + + describe('getTransactionLogsFromElasticInternal', () => { + it('should use events index when configured', async () => { + const hashes = ['hash1']; + apiConfigService.getElasticMigratedIndicesConfig.mockReturnValue({ logs: 'events' }); + + jest.spyOn(service as any, 'getTransactionLogsFromElasticInternalEventsIndex') + .mockResolvedValue([]); + + await service['getTransactionLogsFromElasticInternal'](hashes); + + expect(service['getTransactionLogsFromElasticInternalEventsIndex']).toHaveBeenCalledWith(hashes); + }); + + it('should use logs index by default', async () => { + const hashes = ['hash1']; + apiConfigService.getElasticMigratedIndicesConfig.mockReturnValue({}); + + jest.spyOn(service as any, 'getTransactionLogsFromElasticInternalLogsIndex') + .mockResolvedValue([]); + + await service['getTransactionLogsFromElasticInternal'](hashes); + + expect(service['getTransactionLogsFromElasticInternalLogsIndex']).toHaveBeenCalledWith(hashes); + }); + + it('should use logs index when no config is available', async () => { + const hashes = ['hash1']; + apiConfigService.getElasticMigratedIndicesConfig.mockReturnValue(null as any); + + jest.spyOn(service as any, 'getTransactionLogsFromElasticInternalLogsIndex') + .mockResolvedValue([]); + + await service['getTransactionLogsFromElasticInternal'](hashes); + + expect(service['getTransactionLogsFromElasticInternalLogsIndex']).toHaveBeenCalledWith(hashes); + }); + }); + + describe('getTransactionLogsFromElasticInternalLogsIndex', () => { + it('should call indexer service with correct parameters', async () => { + const hashes = ['hash1', 'hash2']; + const expectedResult = [ + { id: 'hash1', address: 'addr1', identifier: 'test', topics: [], order: 0, originalTxHash: 'hash1' }, + { id: 'hash2', address: 'addr2', identifier: 'test', topics: [], order: 0, originalTxHash: 'hash2' }, + ]; + + indexerService.getTransactionLogs.mockResolvedValue(expectedResult as any); + + const result = await service['getTransactionLogsFromElasticInternalLogsIndex'](hashes); + + expect(indexerService.getTransactionLogs).toHaveBeenCalledWith(hashes, 'logs', '_id'); + expect(result).toEqual(expectedResult); + }); + }); + + describe('getTransactionLogsFromElasticInternalEventsIndex', () => { + const mockEventsData = [ + { + txHash: 'hash1', + logAddress: 'erd1addr1', + identifier: 'ESDTTransfer', + address: 'erd1addr1', + data: '48656c6c6f', // "Hello" in hex + additionalData: ['576f726c64'], // ["World"] in hex + topics: ['746f70696331', '746f70696332'], // ["topic1", "topic2"] in hex + order: 1, + originalTxHash: 'hash1', + }, + { + txHash: 'hash1', + logAddress: 'erd1addr1', + identifier: 'transferValueOnly', + address: 'erd1addr1', + data: '4461746132', // "Data2" in hex + additionalData: undefined, + topics: ['746f70696333'], + order: 2, + originalTxHash: 'hash1', + }, + { + txHash: 'hash2', + logAddress: 'erd1addr2', + identifier: 'writeLog', + address: 'erd1addr2', + data: '', + additionalData: [], + topics: [], + order: 0, + originalTxHash: 'hash2', + }, + ]; + + it('should transform events data correctly', async () => { + indexerService.getTransactionLogs.mockResolvedValue(mockEventsData); + + const result = await service['getTransactionLogsFromElasticInternalEventsIndex'](['hash1', 'hash2']); + + expect(indexerService.getTransactionLogs).toHaveBeenCalledWith(['hash1', 'hash2'], 'events', 'txHash'); + expect(result).toHaveLength(2); + + const hash1Log = result.find(log => log.id === 'hash1'); + expect(hash1Log).toBeDefined(); + if (hash1Log) { + expect(hash1Log.id).toBe('hash1'); + expect(hash1Log.address).toBe('erd1addr1'); + expect(hash1Log.events).toHaveLength(2); + + const firstEvent = hash1Log.events[0]; + expect(firstEvent.identifier).toBe('ESDTTransfer'); + expect(firstEvent.address).toBe('erd1addr1'); + expect(firstEvent.order).toBe(1); + } + + const hash2Log = result.find(log => log.id === 'hash2'); + expect(hash2Log).toBeDefined(); + if (hash2Log) { + expect(hash2Log.id).toBe('hash2'); + expect(hash2Log.events).toHaveLength(1); + } + }); + + it('should handle empty data correctly', async () => { + const emptyDataEvent = { + txHash: 'hash1', + logAddress: 'erd1addr', + identifier: 'test', + address: 'erd1addr', + data: '', + additionalData: [''], + topics: [''], + order: 0, + originalTxHash: 'hash1', + }; + + indexerService.getTransactionLogs.mockResolvedValue([emptyDataEvent]); + + const result = await service['getTransactionLogsFromElasticInternalEventsIndex'](['hash1']); + + expect(result).toHaveLength(1); + const log = result[0]; + expect(log.events).toHaveLength(1); + expect(log.events[0].data).toBe(''); + expect(log.events[0].additionalData).toEqual(['']); + expect(log.events[0].topics).toEqual(['']); + }); + + it('should group events by transaction hash correctly', async () => { + const sameHashEvents = [ + { + txHash: 'hash1', + logAddress: 'erd1addr', + identifier: 'event1', + address: 'erd1addr', + data: '48656c6c6f', + topics: [], + order: 1, + originalTxHash: 'hash1', + }, + { + txHash: 'hash1', + logAddress: 'erd1addr', + identifier: 'event2', + address: 'erd1addr', + data: '576f726c64', + topics: [], + order: 2, + originalTxHash: 'hash1', + }, + ]; + + indexerService.getTransactionLogs.mockResolvedValue(sameHashEvents); + + const result = await service['getTransactionLogsFromElasticInternalEventsIndex'](['hash1']); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('hash1'); + expect(result[0].events).toHaveLength(2); + expect(result[0].events[0].identifier).toBe('event1'); + expect(result[0].events[1].identifier).toBe('event2'); + }); + + it('should return empty array when no events found', async () => { + indexerService.getTransactionLogs.mockResolvedValue([]); + + const result = await service['getTransactionLogsFromElasticInternalEventsIndex'](['hash1']); + + expect(result).toEqual([]); + }); + }); + + describe('tryGetTransactionFromElastic', () => { + const mockTransaction = createMockTransaction({ + hasScResults: true, + hasOperations: true, + senderUserName: 'c2VuZGVy', // "sender" in base64 + receiverUsername: 'cmVjZWl2ZXI=', // "receiver" in base64 + }); + + beforeEach(() => { + indexerService.getTransaction.mockResolvedValue(mockTransaction); + indexerService.getTransactionScResults.mockResolvedValue([]); + indexerService.getTransactionReceipts.mockResolvedValue([]); + jest.spyOn(service, 'getTransactionLogsFromElastic').mockResolvedValue([]); + tokenTransferService.getOperationsForTransaction.mockResolvedValue([]); + jest.spyOn(service as any, 'applyUsernamesToDetailedTransaction').mockImplementation(() => { }); + jest.spyOn(service, 'applyNftNameOnTransactionOperations').mockResolvedValue(); + }); + + it('should return null when transaction not found', async () => { + indexerService.getTransaction.mockResolvedValue(null); + + const result = await service.tryGetTransactionFromElastic(mockTransactionHash); + + expect(result).toBeNull(); + }); + + it('should handle transaction found successfully', async () => { + const result = await service.tryGetTransactionFromElastic(mockTransactionHash); + + expect(result).toBeDefined(); + expect(indexerService.getTransaction).toHaveBeenCalledWith(mockTransactionHash); + }); + + it('should handle scResults field mapping', async () => { + const transactionWithScResults = createMockTransaction({ + hasScResults: true, + hasOperations: true, + scResults: [{ hash: 'scr1' }], + }); + + indexerService.getTransaction.mockResolvedValue(transactionWithScResults); + + const result = await service.tryGetTransactionFromElastic(mockTransactionHash); + + expect(result).toBeDefined(); + }); + + it('should handle relayerAddr field mapping', async () => { + const transactionWithRelayer = createMockTransaction({ + hasScResults: true, + hasOperations: true, + relayerAddr: 'erd1relayer', + }); + + indexerService.getTransaction.mockResolvedValue(transactionWithRelayer); + + const result = await service.tryGetTransactionFromElastic(mockTransactionHash); + + expect(result).toBeDefined(); + }); + + it('should fetch receipts when no fields specified', async () => { + const mockReceipts = [{ + txHash: mockTransactionHash, + receiptHash: 'receipt123', + value: '1000', + sender: mockSender, + data: '', + timestamp: 123456789, + }]; + indexerService.getTransactionReceipts.mockResolvedValue(mockReceipts); + + await service.tryGetTransactionFromElastic(mockTransactionHash); + + expect(indexerService.getTransactionReceipts).toHaveBeenCalledWith(mockTransactionHash); + }); + + it('should skip receipts when receipt field not requested', async () => { + await service.tryGetTransactionFromElastic(mockTransactionHash, ['logs']); + + expect(indexerService.getTransactionReceipts).not.toHaveBeenCalled(); + }); + + it('should fetch logs and operations when requested', async () => { + const mockLogs = [new TransactionLog({ id: mockTransactionHash, events: [] })]; + jest.spyOn(service, 'getTransactionLogsFromElastic').mockResolvedValue(mockLogs); + + await service.tryGetTransactionFromElastic(mockTransactionHash, [TransactionOptionalFieldOption.logs]); + + expect(service.getTransactionLogsFromElastic).toHaveBeenCalled(); + }); + + it('should handle processing error and return null', async () => { + jest.spyOn(service, 'getTransactionLogsFromElastic').mockRejectedValue(new Error('Processing error')); + + const result = await service.tryGetTransactionFromElastic(mockTransactionHash, [TransactionOptionalFieldOption.logs]); + + expect(result).toBeNull(); + }); + }); + + describe('alterDuplicatedTransferValueOnlyEvents', () => { + it('should alter duplicated transferValueOnly events', () => { + const backTransferEncoded = Buffer.from('BackTransfer').toString('base64'); + const asyncCallbackEncoded = Buffer.from('AsyncCallback').toString('base64'); + + const events = [ + new TransactionLogEvent({ + identifier: 'transferValueOnly', + data: backTransferEncoded, + topics: ['topic1', 'topic2'], + }), + new TransactionLogEvent({ + identifier: 'transferValueOnly', + data: asyncCallbackEncoded, + topics: ['topic1', 'topic2'], + }), + new TransactionLogEvent({ + identifier: 'otherEvent', + data: 'otherData', + topics: ['topic3'], + }), + ]; + + service['alterDuplicatedTransferValueOnlyEvents'](events); + + expect(events[1].topics[0]).toBe(Buffer.from('0', 'hex').toString('base64')); + expect(events[0].topics[0]).toBe('topic1'); + expect(events[2].topics[0]).toBe('topic3'); + }); + + it('should not alter when conditions are not met', () => { + const events = [ + new TransactionLogEvent({ + identifier: 'transferValueOnly', + data: Buffer.from('BackTransfer').toString('base64'), + topics: ['topic1'], + }), + ]; + + const originalTopics = [...events[0].topics]; + + service['alterDuplicatedTransferValueOnlyEvents'](events); + + expect(events[0].topics).toEqual(originalTopics); + }); + }); + + describe('tryGetTransactionFromGatewayForList', () => { + it('should return transaction when gateway returns data', async () => { + const mockGatewayTransaction = { + txHash: mockTransactionHash, + sender: mockSender, + receiver: mockReceiver, + }; + + jest.spyOn(service, 'tryGetTransactionFromGateway').mockResolvedValue(mockGatewayTransaction as any); + + const result = await service.tryGetTransactionFromGatewayForList(mockTransactionHash); + + expect(result).toBeDefined(); + expect(service.tryGetTransactionFromGateway).toHaveBeenCalledWith(mockTransactionHash, false); + }); + + it('should return undefined when gateway returns null', async () => { + jest.spyOn(service, 'tryGetTransactionFromGateway').mockResolvedValue(null); + + const result = await service.tryGetTransactionFromGatewayForList(mockTransactionHash); + + expect(result).toBeUndefined(); + }); + }); + + describe('tryGetTransactionFromGateway', () => { + const mockGatewayResponse = createMockGatewayTransaction({ + data: 'test', + receipt: { value: 1000 }, + smartContractResults: [ + { + hash: 'scr1', + callType: 1, + value: 2000, + data: 'Hello', + }, + ], + logs: { events: [] }, + guardian: 'erd1guardian', + guardianSignature: 'guardianSig', + relayerAddress: 'erd1relayer', + relayerSignature: 'relayerSig', + }); + + beforeEach(() => { + gatewayService.getTransaction.mockResolvedValue(mockGatewayResponse); + }); + + it('should return null when gateway returns null', async () => { + gatewayService.getTransaction.mockResolvedValue(null as any); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash); + + expect(result).toBeNull(); + }); + + it('should return null for SmartContractResultBlock', async () => { + gatewayService.getTransaction.mockResolvedValue(createMockGatewayTransaction({ + miniblockType: MiniBlockType.SmartContractResultBlock, + })); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash); + + expect(result).toBeNull(); + }); + + it('should check elastic for pending transactions', async () => { + const pendingTransaction = createMockGatewayTransaction({ + status: 'pending', + }); + + gatewayService.getTransaction.mockResolvedValue(pendingTransaction); + jest.spyOn(service as any, 'tryGetTransactionFromElasticBySenderAndNonce') + .mockResolvedValue(null); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash, true); + + expect(service['tryGetTransactionFromElasticBySenderAndNonce']) + .toHaveBeenCalledWith(mockSender, 1); + expect(result).toBeDefined(); + }); + + it('should return null if different transaction found in elastic', async () => { + const pendingTransaction = createMockGatewayTransaction({ + status: 'pending', + }); + + gatewayService.getTransaction.mockResolvedValue(pendingTransaction); + jest.spyOn(service as any, 'tryGetTransactionFromElasticBySenderAndNonce') + .mockResolvedValue({ txHash: 'different-hash' }); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash, true); + + expect(result).toBeNull(); + }); + + it('should transform gateway response correctly', async () => { + const result = await service.tryGetTransactionFromGateway(mockTransactionHash); + + expect(result).toBeDefined(); + if (result) { + expect(result.txHash).toBe(mockTransactionHash); + expect(result.sender).toBe(mockSender); + expect(result.receiver).toBe(mockReceiver); + expect(result.senderShard).toBe(0); + expect(result.receiverShard).toBe(1); + expect(result.inTransit).toBe(false); + } + }); + + it('should handle inTransit status correctly', async () => { + const pendingTransactionWithMiniblock = createMockGatewayTransaction({ + status: TransactionStatus.pending, + miniblockHash: 'mb123', + }); + + gatewayService.getTransaction.mockResolvedValue(pendingTransactionWithMiniblock); + jest.spyOn(service as any, 'tryGetTransactionFromElasticBySenderAndNonce') + .mockResolvedValue(null); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash, true); + + if (result) { + expect(result.inTransit).toBe(true); + } + }); + + it('should handle error and return null', async () => { + gatewayService.getTransaction.mockRejectedValue(new Error('Gateway error')); + + const result = await service.tryGetTransactionFromGateway(mockTransactionHash); + + expect(result).toBeNull(); + }); + + it('should skip elastic check when queryInElastic is false', async () => { + const pendingTransaction = createMockGatewayTransaction({ + status: 'pending', + }); + + gatewayService.getTransaction.mockResolvedValue(pendingTransaction); + jest.spyOn(service as any, 'tryGetTransactionFromElasticBySenderAndNonce'); + + await service.tryGetTransactionFromGateway(mockTransactionHash, false); + + expect(service['tryGetTransactionFromElasticBySenderAndNonce']).not.toHaveBeenCalled(); + }); + }); + + describe('applyNftNameOnTransactionOperations', () => { + it('should handle empty operations', async () => { + const mockTransactions = [new TransactionDetailed({ operations: [] })]; + + await service.applyNftNameOnTransactionOperations(mockTransactions); + + expect(indexerService.getNfts).not.toHaveBeenCalled(); + }); + + it('should handle transactions without operations', async () => { + const mockTransactions = [new TransactionDetailed()]; + + await service.applyNftNameOnTransactionOperations(mockTransactions); + + expect(indexerService.getNfts).not.toHaveBeenCalled(); + }); + }); + + describe('tryGetTransactionFromElasticBySenderAndNonce', () => { + it('should return first transaction found', async () => { + const mockTransactions = [ + { txHash: 'hash1', sender: mockSender, nonce: 1 }, + { txHash: 'hash2', sender: mockSender, nonce: 1 }, + ]; + + indexerService.getTransactionBySenderAndNonce.mockResolvedValue(mockTransactions as any); + + const result = await service['tryGetTransactionFromElasticBySenderAndNonce'](mockSender, 1); + + expect(result).toEqual(mockTransactions[0]); + expect(indexerService.getTransactionBySenderAndNonce).toHaveBeenCalledWith(mockSender, 1); + }); + + it('should return undefined when no transactions found', async () => { + indexerService.getTransactionBySenderAndNonce.mockResolvedValue([]); + + const result = await service['tryGetTransactionFromElasticBySenderAndNonce'](mockSender, 1); + + expect(result).toBeUndefined(); + }); + }); +});