diff --git a/src/common/gateway/entities/block-info.ts b/src/common/gateway/entities/block-info.ts new file mode 100644 index 000000000..5dec7bb48 --- /dev/null +++ b/src/common/gateway/entities/block-info.ts @@ -0,0 +1,9 @@ +export class BlockInfo { + hash: string = ''; + nonce: number = 0; + rootHash: string = ''; + + constructor(init?: Partial) { + Object.assign(this, init); + } +} diff --git a/src/common/gateway/entities/gateway.component.request.ts b/src/common/gateway/entities/gateway.component.request.ts index 066b06904..e2a9848d5 100644 --- a/src/common/gateway/entities/gateway.component.request.ts +++ b/src/common/gateway/entities/gateway.component.request.ts @@ -10,6 +10,7 @@ export enum GatewayComponentRequest { addressEsdtHistorical = 'addressEsdtHistorical', addressEsdtBalance = 'addressEsdtBalance', addressNfts = 'addressNfts', + addressIterateKeys = 'addressIterateKeys', nodeHeartbeat = 'nodeHeartbeat', getNodeWaitingEpochsLeft = 'getNodeWaitingEpochsLeft', validatorStatistics = 'validatorStatistics', diff --git a/src/common/gateway/entities/iterate-keys-request.ts b/src/common/gateway/entities/iterate-keys-request.ts new file mode 100644 index 000000000..e94fcb00a --- /dev/null +++ b/src/common/gateway/entities/iterate-keys-request.ts @@ -0,0 +1,9 @@ +export class IterateKeysRequest { + constructor(init?: Partial) { + Object.assign(this, init); + } + + address: string = ''; + numKeys: number = 0; + iteratorState: string[] = []; +} diff --git a/src/common/gateway/entities/iterate-keys-response.ts b/src/common/gateway/entities/iterate-keys-response.ts new file mode 100644 index 000000000..cdd30c9b2 --- /dev/null +++ b/src/common/gateway/entities/iterate-keys-response.ts @@ -0,0 +1,15 @@ +import { BlockInfo } from './block-info'; + +export class IterateKeysResponse { + blockInfo?: BlockInfo; + newIteratorState: string[] = []; + pairs: { [key: string]: string } = {}; + + constructor(init?: Partial) { + Object.assign(this, init); + + if (init?.blockInfo) { + this.blockInfo = new BlockInfo(init.blockInfo); + } + } +} diff --git a/src/common/gateway/gateway.service.ts b/src/common/gateway/gateway.service.ts index 45d5c827d..7c897f5b7 100644 --- a/src/common/gateway/gateway.service.ts +++ b/src/common/gateway/gateway.service.ts @@ -3,6 +3,8 @@ import { Auction } from "./entities/auction"; import { EsdtAddressRoles } from "./entities/esdt.roles"; import { EsdtSupply } from "./entities/esdt.supply"; import { GatewayComponentRequest } from "./entities/gateway.component.request"; +import { IterateKeysRequest } from "./entities/iterate-keys-request"; +import { IterateKeysResponse } from "./entities/iterate-keys-response"; import { MetricsEvents } from "src/utils/metrics-events.constants"; import { LogPerformanceAsync } from "src/utils/log.performance.decorator"; import { HeartbeatStatus } from "./entities/heartbeat.status"; @@ -39,6 +41,7 @@ export class GatewayService { GatewayComponentRequest.addressEsdt, GatewayComponentRequest.addressEsdtBalance, GatewayComponentRequest.addressNftByNonce, + GatewayComponentRequest.addressIterateKeys, GatewayComponentRequest.vmQuery, ]); @@ -195,6 +198,20 @@ export class GatewayService { return result.block; } + async getAddressIterateKeys(request: IterateKeysRequest): Promise { + // eslint-disable-next-line require-await + const result = await this.create('address/iterate-keys', GatewayComponentRequest.addressIterateKeys, request, async (error) => { + const errorMessage = error?.response?.data?.error; + if (errorMessage && errorMessage.includes('account was not found')) { + return true; + } + + return false; + }); + + return new IterateKeysResponse(result); + } + @LogPerformanceAsync(MetricsEvents.SetGatewayDuration, { argIndex: 1 }) async get(url: string, component: GatewayComponentRequest, errorHandler?: (error: any) => Promise): Promise { const result = await this.getRaw(url, component, errorHandler); diff --git a/src/endpoints/accounts/account.controller.ts b/src/endpoints/accounts/account.controller.ts index 0c23f88e4..84cff5935 100644 --- a/src/endpoints/accounts/account.controller.ts +++ b/src/endpoints/accounts/account.controller.ts @@ -1,9 +1,11 @@ -import { Controller, DefaultValuePipe, Get, HttpException, HttpStatus, NotFoundException, Param, Query, UseInterceptors } from '@nestjs/common'; +import { Controller, DefaultValuePipe, Get, HttpException, HttpStatus, NotFoundException, Param, Query, UseInterceptors, Post, Body } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { AccountService } from './account.service'; import { AccountDetailed } from './entities/account.detailed'; import { Account } from './entities/account'; import { AccountDeferred } from './entities/account.deferred'; +import { IterateKeysRequestDto } from './entities/iterate-keys-request.dto'; +import { IterateKeysResponseDto } from './entities/iterate-keys-response.dto'; import { TokenService } from '../tokens/token.service'; import { TokenWithBalance } from '../tokens/entities/token.with.balance'; import { DelegationLegacyService } from '../delegation.legacy/delegation.legacy.service'; @@ -1428,4 +1430,36 @@ export class AccountController { new QueryPagination({ from, size }), new AccountHistoryFilter({ before, after })); } + + @Post("/accounts/:address/iterate-keys") + @UseInterceptors(DeepHistoryInterceptor) + @ApiOperation({ + summary: 'Iterate account storage keys', + description: 'Returns paginated account storage keys with state-based iteration. Supports deep history via timestamp query parameter.', + }) + @ApiQuery({ + name: 'timestamp', + description: 'Retrieve keys from specific timestamp (requires deep history)', + required: false, + type: Number, + }) + @ApiOkResponse({ type: IterateKeysResponseDto }) + async getAccountIterateKeys( + @Param('address', ParseAddressPipe) address: string, + @Body() request: IterateKeysRequestDto, + @Query('timestamp', ParseIntPipe) _timestamp?: number, + ): Promise { + try { + const result = await this.accountService.getIterateKeys(address, request); + return { + blockInfo: result.blockInfo, + newIteratorState: result.newIteratorState, + pairs: result.pairs, + }; + } catch (error) { + this.logger.error(`Error in getAccountIterateKeys for address ${address}`); + this.logger.error(error); + throw new HttpException('Failed to iterate keys', HttpStatus.INTERNAL_SERVER_ERROR); + } + } } diff --git a/src/endpoints/accounts/account.service.ts b/src/endpoints/accounts/account.service.ts index cde2c8564..dc9dcf109 100644 --- a/src/endpoints/accounts/account.service.ts +++ b/src/endpoints/accounts/account.service.ts @@ -1,6 +1,9 @@ import { forwardRef, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { AccountDetailed } from './entities/account.detailed'; import { Account } from './entities/account'; +import { IterateKeysRequestDto } from './entities/iterate-keys-request.dto'; +import { IterateKeysResponse } from 'src/common/gateway/entities/iterate-keys-response'; +import { IterateKeysRequest } from 'src/common/gateway/entities/iterate-keys-request'; import { VmQueryService } from 'src/endpoints/vm.query/vm.query.service'; import { ApiConfigService } from 'src/common/api-config/api.config.service'; import { AccountDeferred } from './entities/account.deferred'; @@ -745,4 +748,14 @@ export class AccountService { transfers24H: item.value, })); } + + async getIterateKeys(address: string, request: IterateKeysRequestDto): Promise { + const gatewayRequest = new IterateKeysRequest({ + address, + numKeys: request.numKeys, + iteratorState: request.iteratorState, + }); + + return await this.gatewayService.getAddressIterateKeys(gatewayRequest); + } } diff --git a/src/endpoints/accounts/entities/iterate-keys-request.dto.ts b/src/endpoints/accounts/entities/iterate-keys-request.dto.ts new file mode 100644 index 000000000..d20913431 --- /dev/null +++ b/src/endpoints/accounts/entities/iterate-keys-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class IterateKeysRequestDto { + @ApiProperty({ + description: 'Number of keys to retrieve. Set to 0 to retrieve keys until timeout is reached.', + example: 10, + minimum: 0, + }) + numKeys: number = 0; + + @ApiProperty({ + description: 'Iterator state for pagination. Empty array for the first request, use returned newIteratorState for subsequent requests.', + example: [], + type: [String], + }) + iteratorState: string[] = []; +} diff --git a/src/endpoints/accounts/entities/iterate-keys-response.dto.ts b/src/endpoints/accounts/entities/iterate-keys-response.dto.ts new file mode 100644 index 000000000..6bd1db69e --- /dev/null +++ b/src/endpoints/accounts/entities/iterate-keys-response.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BlockInfoDto { + @ApiProperty({ description: 'Block hash' }) + hash: string = ''; + + @ApiProperty({ description: 'Block nonce' }) + nonce: number = 0; + + @ApiProperty({ description: 'Block root hash' }) + rootHash: string = ''; +} + +export class IterateKeysResponseDto { + @ApiProperty({ + description: 'Block information for consistency guarantees', + type: BlockInfoDto, + required: false, + }) + blockInfo?: BlockInfoDto; + + @ApiProperty({ + description: 'Iterator state for the next request. Empty array indicates no more keys available.', + type: [String], + }) + newIteratorState: string[] = []; + + @ApiProperty({ + description: 'Key-value pairs from the account storage. Keys and values are hex-encoded.', + type: 'object', + additionalProperties: { type: 'string' }, + }) + pairs: { [key: string]: string } = {}; +} diff --git a/src/endpoints/proxy/gateway.proxy.controller.ts b/src/endpoints/proxy/gateway.proxy.controller.ts index 3b7e12c37..b62e337ac 100644 --- a/src/endpoints/proxy/gateway.proxy.controller.ts +++ b/src/endpoints/proxy/gateway.proxy.controller.ts @@ -11,6 +11,7 @@ import { CacheService, NoCache } from "@multiversx/sdk-nestjs-cache"; import { OriginLogger } from "@multiversx/sdk-nestjs-common"; import { DeepHistoryInterceptor } from "src/interceptors/deep-history.interceptor"; import { DisableFieldsInterceptorOnController } from "@multiversx/sdk-nestjs-http"; +import { IterateKeysRequest } from "src/common/gateway/entities/iterate-keys-request"; @Controller() @ApiTags('proxy') @@ -91,6 +92,19 @@ export class GatewayProxyController { }); } + @Post('/address/iterate-keys') + async iterateKeys(@Body() request: IterateKeysRequest) { + // eslint-disable-next-line require-await + return await this.gatewayPost('address/iterate-keys', GatewayComponentRequest.addressIterateKeys, request, async (error) => { + const errorMessage = error?.response?.data?.error; + if (errorMessage && errorMessage.includes('account was not found')) { + throw error; + } + + return false; + }); + } + @Post('/transaction/send') async transactionSend(@Body() body: any) { if (!body.sender) {