diff --git a/src/common/indexer/elastic/elastic.indexer.helper.ts b/src/common/indexer/elastic/elastic.indexer.helper.ts index b02b9dde5..be10ff0a9 100644 --- a/src/common/indexer/elastic/elastic.indexer.helper.ts +++ b/src/common/indexer/elastic/elastic.indexer.helper.ts @@ -17,6 +17,7 @@ import { AccountHistoryFilter } from "src/endpoints/accounts/entities/account.hi import { SmartContractResultFilter } from "src/endpoints/sc-results/entities/smart.contract.result.filter"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; import { NftType } from "../entities/nft.type"; +import { EventsFilter } from "src/endpoints/events/entities/events.filter"; @Injectable() export class ElasticIndexerHelper { @@ -717,4 +718,34 @@ export class ElasticIndexerHelper { } return elasticQuery.withMustCondition(QueryType.Should(functionConditions)); } + + public buildEventsFilter(filter: EventsFilter): ElasticQuery { + let elasticQuery = ElasticQuery.create(); + + if (filter.before) { + elasticQuery = elasticQuery.withRangeFilter('timestamp', new RangeLowerThanOrEqual(filter.before)); + } + + if (filter.after) { + elasticQuery = elasticQuery.withRangeFilter('timestamp', new RangeGreaterThanOrEqual(filter.after)); + } + + if (filter.identifier) { + elasticQuery = elasticQuery.withMustMatchCondition('identifier', filter.identifier); + } + + if (filter.txHash) { + elasticQuery = elasticQuery.withMustMatchCondition('txHash', filter.txHash); + } + + if (filter.shard) { + elasticQuery = elasticQuery.withCondition(QueryConditionOptions.must, QueryType.Match('shardID', filter.shard)); + } + + if (filter.address) { + elasticQuery = elasticQuery.withMustMatchCondition('address', filter.address); + } + + return elasticQuery; + } } diff --git a/src/common/indexer/elastic/elastic.indexer.service.ts b/src/common/indexer/elastic/elastic.indexer.service.ts index 550297a0e..01e399613 100644 --- a/src/common/indexer/elastic/elastic.indexer.service.ts +++ b/src/common/indexer/elastic/elastic.indexer.service.ts @@ -27,6 +27,8 @@ import { AccountAssets } from "src/common/assets/entities/account.assets"; import { NotWritableError } from "../entities/not.writable.error"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; import { NftType } from "../entities/nft.type"; +import { EventsFilter } from "src/endpoints/events/entities/events.filter"; +import { Events } from "../entities/events"; @Injectable() export class ElasticIndexerService implements IndexerInterface { @@ -975,4 +977,22 @@ export class ElasticIndexerService implements IndexerInterface { return await this.elasticService.getCount('scdeploys', elasticQuery); } + + async getEvents(pagination: QueryPagination, filter: EventsFilter): Promise { + const elasticQuery = this.indexerHelper.buildEventsFilter(filter) + .withPagination(pagination) + .withSort([{ name: 'timestamp', order: ElasticSortOrder.descending }]); + + return await this.elasticService.getList('events', '_id', elasticQuery); + } + + async getEvent(txHash: string): Promise { + return await this.elasticService.getItem('events', '_id', txHash); + } + + async getEventsCount(filter: EventsFilter): Promise { + const elasticQuery = this.indexerHelper.buildEventsFilter(filter); + + return await this.elasticService.getCount('events', elasticQuery); + } } diff --git a/src/common/indexer/entities/events.ts b/src/common/indexer/entities/events.ts new file mode 100644 index 000000000..1d67492f8 --- /dev/null +++ b/src/common/indexer/entities/events.ts @@ -0,0 +1,13 @@ +export class Events { + _id: string = ''; + logAddress: string = ''; + identifier: string = ''; + address: string = ''; + data: string = ''; + topics: string[] = []; + shardID: number = 0; + additionalData: string[] = []; + txOrder: number = 0; + order: number = 0; + timestamp: number = 0; +} diff --git a/src/common/indexer/indexer.interface.ts b/src/common/indexer/indexer.interface.ts index a2f4e62b4..0b17283c2 100644 --- a/src/common/indexer/indexer.interface.ts +++ b/src/common/indexer/indexer.interface.ts @@ -16,6 +16,8 @@ import { Account, AccountHistory, AccountTokenHistory, Block, Collection, MiniBl import { AccountAssets } from "../assets/entities/account.assets"; import { ProviderDelegators } from "./entities/provider.delegators"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; +import { EventsFilter } from "src/endpoints/events/entities/events.filter"; +import { Events } from "./entities/events"; export interface IndexerInterface { getAccountsCount(filter: AccountQueryOptions): Promise @@ -108,7 +110,7 @@ export interface IndexerInterface { getAccountContracts(pagination: QueryPagination, address: string): Promise - getAccountContractsCount( address: string): Promise + getAccountContractsCount(address: string): Promise getAccountHistory(address: string, pagination: QueryPagination, filter: AccountHistoryFilter): Promise @@ -185,4 +187,10 @@ export interface IndexerInterface { getApplications(filter: ApplicationFilter, pagination: QueryPagination): Promise getApplicationCount(filter: ApplicationFilter): Promise + + getEvents(pagination: QueryPagination, filter: EventsFilter): Promise + + getEvent(txHash: string): Promise + + getEventsCount(filter: EventsFilter): Promise } diff --git a/src/common/indexer/indexer.service.ts b/src/common/indexer/indexer.service.ts index b4fc8c24c..429107aed 100644 --- a/src/common/indexer/indexer.service.ts +++ b/src/common/indexer/indexer.service.ts @@ -20,6 +20,8 @@ import { AccountHistoryFilter } from "src/endpoints/accounts/entities/account.hi import { AccountAssets } from "../assets/entities/account.assets"; import { ProviderDelegators } from "./entities/provider.delegators"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; +import { EventsFilter } from "src/endpoints/events/entities/events.filter"; +import { Events } from "./entities/events"; @Injectable() export class IndexerService implements IndexerInterface { @@ -448,4 +450,19 @@ export class IndexerService implements IndexerInterface { async getApplicationCount(filter: ApplicationFilter): Promise { return await this.indexerInterface.getApplicationCount(filter); } + + @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) + async getEvents(pagination: QueryPagination, filter: EventsFilter): Promise { + return await this.indexerInterface.getEvents(pagination, filter); + } + + @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) + async getEvent(txHash: string): Promise { + return await this.indexerInterface.getEvent(txHash); + } + + @LogPerformanceAsync(MetricsEvents.SetIndexerDuration) + async getEventsCount(filter: EventsFilter): Promise { + return await this.indexerInterface.getEventsCount(filter); + } } diff --git a/src/common/indexer/postgres/postgres.indexer.service.ts b/src/common/indexer/postgres/postgres.indexer.service.ts index fa3cb1bbe..4999f9c80 100644 --- a/src/common/indexer/postgres/postgres.indexer.service.ts +++ b/src/common/indexer/postgres/postgres.indexer.service.ts @@ -21,6 +21,8 @@ import { PostgresIndexerHelper } from "./postgres.indexer.helper"; import { AccountAssets } from "src/common/assets/entities/account.assets"; import { ProviderDelegators } from "../entities/provider.delegators"; import { ApplicationFilter } from "src/endpoints/applications/entities/application.filter"; +import { EventsFilter } from "src/endpoints/events/entities/events.filter"; +import { Events } from "../entities/events"; @Injectable() export class PostgresIndexerService implements IndexerInterface { @@ -53,6 +55,15 @@ export class PostgresIndexerService implements IndexerInterface { private readonly validatorPublicKeysRepository: Repository, private readonly indexerHelper: PostgresIndexerHelper, ) { } + getEvents(_pagination: QueryPagination, _filter: EventsFilter): Promise { + throw new Error("Method not implemented."); + } + getEvent(_txHash: string): Promise { + throw new Error("Method not implemented."); + } + getEventsCount(_filter: EventsFilter): Promise { + throw new Error("Method not implemented."); + } getAccountDeploys(_pagination: QueryPagination, _address: string): Promise { throw new Error("Method not implemented."); @@ -402,12 +413,12 @@ export class PostgresIndexerService implements IndexerInterface { return await query.getMany(); } - getAccountContracts(): Promise { + getAccountContracts(): Promise { throw new Error("Method not implemented."); } getAccountContractsCount(): Promise { - throw new Error("Method not implemented."); + throw new Error("Method not implemented."); } async getAccountHistory(address: string, { from, size }: QueryPagination): Promise { diff --git a/src/endpoints/endpoints.controllers.module.ts b/src/endpoints/endpoints.controllers.module.ts index c2b51497b..318e527f0 100644 --- a/src/endpoints/endpoints.controllers.module.ts +++ b/src/endpoints/endpoints.controllers.module.ts @@ -38,6 +38,7 @@ import { WebsocketController } from "./websocket/websocket.controller"; import { PoolController } from "./pool/pool.controller"; import { TpsController } from "./tps/tps.controller"; import { ApplicationController } from "./applications/application.controller"; +import { EventsController } from "./events/events.controller"; @Module({}) export class EndpointsControllersModule { @@ -48,7 +49,7 @@ export class EndpointsControllersModule { ProviderController, GatewayProxyController, RoundController, SmartContractResultController, ShardController, StakeController, StakeController, TokenController, TransactionController, UsernameController, VmQueryController, WaitingListController, HealthCheckController, DappConfigController, WebsocketController, TransferController, - ProcessNftsPublicController, TransactionsBatchController, ApplicationController, + ProcessNftsPublicController, TransactionsBatchController, ApplicationController, EventsController, ]; const isMarketplaceFeatureEnabled = configuration().features?.marketplace?.enabled ?? false; diff --git a/src/endpoints/endpoints.services.module.ts b/src/endpoints/endpoints.services.module.ts index bf4aa9501..fc8531828 100644 --- a/src/endpoints/endpoints.services.module.ts +++ b/src/endpoints/endpoints.services.module.ts @@ -35,6 +35,7 @@ import { WebsocketModule } from "./websocket/websocket.module"; import { PoolModule } from "./pool/pool.module"; import { TpsModule } from "./tps/tps.module"; import { ApplicationModule } from "./applications/application.module"; +import { EventsModule } from "./events/events.module"; @Module({ imports: [ @@ -75,13 +76,14 @@ import { ApplicationModule } from "./applications/application.module"; TransactionsBatchModule, TpsModule, ApplicationModule, + EventsModule, ], exports: [ AccountModule, CollectionModule, BlockModule, DelegationModule, DelegationLegacyModule, IdentitiesModule, KeysModule, MiniBlockModule, NetworkModule, NftModule, NftMediaModule, TagModule, NodeModule, ProviderModule, RoundModule, SmartContractResultModule, ShardModule, StakeModule, TokenModule, RoundModule, TransactionModule, UsernameModule, VmQueryModule, WaitingListModule, EsdtModule, BlsModule, DappConfigModule, TransferModule, PoolModule, TransactionActionModule, WebsocketModule, MexModule, - ProcessNftsModule, NftMarketplaceModule, TransactionsBatchModule, TpsModule, ApplicationModule, + ProcessNftsModule, NftMarketplaceModule, TransactionsBatchModule, TpsModule, ApplicationModule, EventsModule, ], }) export class EndpointsServicesModule { } diff --git a/src/endpoints/events/entities/events.filter.ts b/src/endpoints/events/entities/events.filter.ts new file mode 100644 index 000000000..b8da8e3db --- /dev/null +++ b/src/endpoints/events/entities/events.filter.ts @@ -0,0 +1,13 @@ + +export class EventsFilter { + constructor(init?: Partial) { + Object.assign(this, init); + } + + identifier: string = ''; + address: string = ''; + txHash: string = ''; + shard: number = 0; + before: number = 0; + after: number = 0; +} diff --git a/src/endpoints/events/entities/events.ts b/src/endpoints/events/entities/events.ts new file mode 100644 index 000000000..00d5909b1 --- /dev/null +++ b/src/endpoints/events/entities/events.ts @@ -0,0 +1,42 @@ +import { ObjectType } from '@nestjs/graphql'; +import { ApiProperty } from '@nestjs/swagger'; + +@ObjectType("Events", { description: "Events object type." }) +export class Events { + constructor(init?: Partial) { + Object.assign(this, init); + } + + @ApiProperty({ description: "Transaction hash." }) + txHash: string = ''; + + @ApiProperty({ description: "Log address." }) + logAddress: string = ''; + + @ApiProperty({ description: "Event identifier." }) + identifier: string = ''; + + @ApiProperty({ description: "Event address." }) + address: string = ''; + + @ApiProperty({ description: "Event data." }) + data: string = ''; + + @ApiProperty({ description: "Event topics." }) + topics: string[] = []; + + @ApiProperty({ description: "Event shard ID." }) + shardID: number = 0; + + @ApiProperty({ description: "Event additional data." }) + additionalData: string[] = []; + + @ApiProperty({ description: "Event tx order." }) + txOrder: number = 0; + + @ApiProperty({ description: "Event block order." }) + order: number = 0; + + @ApiProperty({ description: "Event timestamp." }) + timestamp: number = 0; +} diff --git a/src/endpoints/events/events.controller.ts b/src/endpoints/events/events.controller.ts new file mode 100644 index 000000000..e58a1f6e8 --- /dev/null +++ b/src/endpoints/events/events.controller.ts @@ -0,0 +1,78 @@ +import { Controller, DefaultValuePipe, Get, NotFoundException, Param, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { EventsService } from './events.service'; +import { QueryPagination } from '../../common/entities/query.pagination'; +import { ParseAddressPipe, ParseIntPipe } from '@multiversx/sdk-nestjs-common'; + +import { Events } from './entities/events'; +import { EventsFilter } from './entities/events.filter'; + +@Controller() +@ApiTags('events') +export class EventsController { + constructor( + private readonly eventsService: EventsService, + ) { } + + @Get('/events') + @ApiOperation({ summary: 'Events', description: 'Returns events' }) + @ApiOkResponse({ type: [Events] }) + @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: '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 }) + async getEvents( + @Query('from', new DefaultValuePipe(0), ParseIntPipe) from: number, + @Query('size', new DefaultValuePipe(25), ParseIntPipe) size: number, + @Query('address', ParseAddressPipe) address: string, + @Query('identifier') identifier: string, + @Query('txHash') txHash: string, + @Query('shard', ParseIntPipe) shard: number, + @Query('before', ParseIntPipe) before: number, + @Query('after', ParseIntPipe) after: number, + ): Promise { + return await this.eventsService.getEvents( + new QueryPagination({ from, size }), + new EventsFilter({ address, identifier, txHash, shard, after, before })); + } + + @Get('/events/count') + @ApiOperation({ summary: 'Events count', description: 'Returns events count' }) + @ApiOkResponse({ type: Number }) + @ApiQuery({ name: 'address', description: 'Event 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 }) + async getEventsCount( + @Query('address', ParseAddressPipe) address: string, + @Query('identifier') identifier: string, + @Query('txHash') txHash: string, + @Query('shard', ParseIntPipe) shard: number, + @Query('before', ParseIntPipe) before: number, + @Query('after', ParseIntPipe) after: number, + ): Promise { + return await this.eventsService.getEventsCount( + new EventsFilter({ address, identifier, txHash, shard, after, before })); + } + + @Get('/events/:txHash') + @ApiOperation({ summary: 'Event', description: 'Returns event' }) + @ApiOkResponse({ type: Events }) + async getEvent( + @Param('txHash') txHash: string, + ): Promise { + const result = await this.eventsService.getEvent(txHash); + + if (!result) { + throw new NotFoundException('Event not found'); + } + + return result; + } +} diff --git a/src/endpoints/events/events.module.ts b/src/endpoints/events/events.module.ts new file mode 100644 index 000000000..9cdf3f729 --- /dev/null +++ b/src/endpoints/events/events.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EventsService } from './events.service'; + +@Module({ + providers: [EventsService], + exports: [EventsService], +}) +export class EventsModule { } diff --git a/src/endpoints/events/events.service.ts b/src/endpoints/events/events.service.ts new file mode 100644 index 000000000..e8c265600 --- /dev/null +++ b/src/endpoints/events/events.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { IndexerService } from '../../common/indexer/indexer.service'; +import { QueryPagination } from '../../common/entities/query.pagination'; +import { Events } from './entities/events'; +import { Events as IndexerEvents } from '../../common/indexer/entities/events'; +import { EventsFilter } from './entities/events.filter'; + +@Injectable() +export class EventsService { + constructor( + private readonly indexerService: IndexerService, + ) { } + + async getEvents(pagination: QueryPagination, filter: EventsFilter): Promise { + const results = await this.indexerService.getEvents(pagination, filter); + + return results ? results.map(this.mapEvent) : []; + } + + async getEvent(txHash: string): Promise { + const result = await this.indexerService.getEvent(txHash); + + return result ? new Events(this.mapEvent(result)) : undefined; + } + + async getEventsCount(filter: EventsFilter): Promise { + return await this.indexerService.getEventsCount(filter); + } + + private mapEvent(eventData: IndexerEvents): Events { + return new Events({ + txHash: eventData._id, + logAddress: eventData.logAddress, + identifier: eventData.identifier, + address: eventData.address, + data: eventData.data, + topics: eventData.topics, + shardID: eventData.shardID, + additionalData: eventData.additionalData, + txOrder: eventData.txOrder, + order: eventData.order, + timestamp: eventData.timestamp, + }); + } +} diff --git a/src/test/unit/services/events.spec.ts b/src/test/unit/services/events.spec.ts new file mode 100644 index 000000000..66c21324e --- /dev/null +++ b/src/test/unit/services/events.spec.ts @@ -0,0 +1,122 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { QueryPagination } from 'src/common/entities/query.pagination'; +import { Events as IndexerEvents } from 'src/common/indexer/entities/events'; +import { IndexerService } from 'src/common/indexer/indexer.service'; +import { Events } from 'src/endpoints/events/entities/events'; +import { EventsFilter } from 'src/endpoints/events/entities/events.filter'; +import { EventsService } from 'src/endpoints/events/events.service'; + +describe('EventsService', () => { + let service: EventsService; + let indexerService: IndexerService; + + const mockIndexerService = { + getEvents: jest.fn(), + getEventsCount: jest.fn(), + }; + + const baseMockEventData = { + logAddress: "erd1qqqqqqqqqqqqqpgq5lgsm8lsen2gv65gwtrs25js0ktx7ltgusrqeltmln", + address: "erd1qqqqqqqqqqqqqpgq5lgsm8lsen2gv65gwtrs25js0ktx7ltgusrqeltmln", + data: "44697265637443616c6c", + topics: [ + "2386f26fc10000", + "ec5d314f9bbf727d88c802fd407caa971ebad708cfdd311e74d7762b6abce406", + ], + shardID: 2, + additionalData: ["44697265637443616c6c", ""], + txOrder: 0, + order: 2, + timestamp: 1727543874, + }; + + const generateMockEvent = (overrides = {}): IndexerEvents => ({ + _id: "7e3faa2a4ea5cfe8667f2e13eb27076b0452742dbe01044871c8ea109f73ebed", + identifier: "transferValueOnly", + ...baseMockEventData, + ...overrides, + }); + + const createExpectedEvent = (txHash: string, identifier: string) => new Events({ + txHash, + identifier, + ...baseMockEventData, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventsService, + { provide: IndexerService, useValue: mockIndexerService }, + ], + }).compile(); + + service = module.get(EventsService); + indexerService = module.get(IndexerService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getEvents', () => { + it('should return a list of events with mapped fields', async () => { + const pagination: QueryPagination = { from: 0, size: 10 }; + const filter: EventsFilter = new EventsFilter(); + + const mockElasticEvents = [ + generateMockEvent(), + generateMockEvent({ _id: "5d4a7cd39caf55aaaef038d2fe5fd864b01db2170253c158-1-1", identifier: 'ESDTNFTCreate' }), + ]; + + const expectedEvents = [ + createExpectedEvent("7e3faa2a4ea5cfe8667f2e13eb27076b0452742dbe01044871c8ea109f73ebed", "transferValueOnly"), + createExpectedEvent("5d4a7cd39caf55aaaef038d2fe5fd864b01db2170253c158-1-1", "ESDTNFTCreate"), + ]; + + mockIndexerService.getEvents.mockResolvedValue(mockElasticEvents); + + const result = await service.getEvents(pagination, filter); + + expect(result).toEqual(expectedEvents); + expect(indexerService.getEvents).toHaveBeenCalledWith(pagination, filter); + }); + + it('should return an empty list if no events are found', async () => { + const pagination: QueryPagination = { from: 0, size: 10 }; + const filter: EventsFilter = new EventsFilter(); + + mockIndexerService.getEvents.mockResolvedValue([]); + + const result = await service.getEvents(pagination, filter); + + expect(result).toEqual([]); + expect(indexerService.getEvents).toHaveBeenCalledWith(pagination, filter); + }); + }); + + describe('getEventsCount', () => { + it('should return the count of events', async () => { + const filter: EventsFilter = new EventsFilter(); + const mockCount = 42; + + mockIndexerService.getEventsCount.mockResolvedValue(mockCount); + + const result = await service.getEventsCount(filter); + + expect(result).toEqual(mockCount); + expect(indexerService.getEventsCount).toHaveBeenCalledWith(filter); + }); + + it('should return zero if no events are found', async () => { + const filter: EventsFilter = new EventsFilter(); + + mockIndexerService.getEventsCount.mockResolvedValue(0); + + const result = await service.getEventsCount(filter); + + expect(result).toEqual(0); + expect(indexerService.getEventsCount).toHaveBeenCalledWith(filter); + }); + }); +});