diff --git a/packages/api/src/api/account/account.controller.spec.ts b/packages/api/src/api/account/account.controller.spec.ts index abe0b3e083..921bf8e54e 100644 --- a/packages/api/src/api/account/account.controller.spec.ts +++ b/packages/api/src/api/account/account.controller.spec.ts @@ -149,12 +149,15 @@ describe("AccountController", () => { describe("getAccountTransactions", () => { it("calls block service to get latest block number", async () => { - await controller.getAccountTransactions(address, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + await controller.getAccountTransactions( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(blockServiceMock.getLastBlockNumber).toBeCalledTimes(1); }); @@ -165,8 +168,8 @@ describe("AccountController", () => { page: 2, offset: 20, maxLimit: 10000, - sort: SortingOrder.Asc, }, + { sort: SortingOrder.Asc }, 11, 12 ); @@ -182,12 +185,15 @@ describe("AccountController", () => { }); it("returns not ok response when no transactions found", async () => { - const response = await controller.getAccountTransactions(address, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountTransactions( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.NOTOK, message: ResponseMessage.NO_TRANSACTIONS_FOUND, @@ -198,12 +204,15 @@ describe("AccountController", () => { it("returns transactions list when transactions are found by address", async () => { jest.spyOn(transactionServiceMock, "findByAddress").mockResolvedValue([addressTransaction as AddressTransaction]); - const response = await controller.getAccountTransactions(address, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountTransactions( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.OK, message: ResponseMessage.OK, @@ -250,8 +259,8 @@ describe("AccountController", () => { page: 2, offset: 20, maxLimit: 10000, - sort: SortingOrder.Asc, }, + { sort: SortingOrder.Asc }, 11, 12 ); @@ -269,12 +278,16 @@ describe("AccountController", () => { }); it("returns not ok response when no transactions found", async () => { - const response = await controller.getAccountInternalTransactions(address, null, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountInternalTransactions( + address, + null, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.NOTOK, message: ResponseMessage.NO_TRANSACTIONS_FOUND, @@ -285,12 +298,16 @@ describe("AccountController", () => { it("returns internal transactions list when transactions are found", async () => { jest.spyOn(transferServiceMock, "findInternalTransfers").mockResolvedValue([ecr20Transfer as Transfer]); - const response = await controller.getAccountInternalTransactions(address, null, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountInternalTransactions( + address, + null, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.OK, message: ResponseMessage.OK, @@ -320,12 +337,16 @@ describe("AccountController", () => { describe("getAccountTokenTransfers", () => { it("calls block service to get latest block number", async () => { - await controller.getAccountTokenTransfers(address, null, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + await controller.getAccountTokenTransfers( + address, + null, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(blockServiceMock.getLastBlockNumber).toBeCalledTimes(1); }); @@ -337,8 +358,8 @@ describe("AccountController", () => { page: 2, offset: 20, maxLimit: 10000, - sort: SortingOrder.Asc, }, + { sort: SortingOrder.Asc }, 11, 12 ); @@ -357,12 +378,16 @@ describe("AccountController", () => { }); it("returns not ok response when no transfers found", async () => { - const response = await controller.getAccountTokenTransfers(address, "tokenAddress", { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountTokenTransfers( + address, + "tokenAddress", + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.NOTOK, message: ResponseMessage.NO_TRANSACTIONS_FOUND, @@ -380,8 +405,8 @@ describe("AccountController", () => { page: 2, offset: 20, maxLimit: 10000, - sort: SortingOrder.Asc, - } + }, + { sort: SortingOrder.Asc } ); expect(response).toEqual({ status: ResponseStatus.OK, @@ -417,12 +442,16 @@ describe("AccountController", () => { describe("getAccountNFTTransfers", () => { it("calls block service to get latest block number", async () => { - await controller.getAccountNFTTransfers(address, null, { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + await controller.getAccountNFTTransfers( + address, + null, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(blockServiceMock.getLastBlockNumber).toBeCalledTimes(1); }); @@ -434,8 +463,8 @@ describe("AccountController", () => { page: 2, offset: 20, maxLimit: 10000, - sort: SortingOrder.Asc, }, + { sort: SortingOrder.Asc }, 11, 12 ); @@ -454,12 +483,16 @@ describe("AccountController", () => { }); it("returns not ok response when no transfers found", async () => { - const response = await controller.getAccountNFTTransfers(address, "tokenAddress", { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountNFTTransfers( + address, + "tokenAddress", + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.NOTOK, message: ResponseMessage.NO_TRANSACTIONS_FOUND, @@ -470,12 +503,16 @@ describe("AccountController", () => { it("returns transfers list when transfers are found", async () => { jest.spyOn(transferServiceMock, "findTokenTransfers").mockResolvedValue([erc721Transfer]); - const response = await controller.getAccountNFTTransfers(address, "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe36A", { - page: 2, - offset: 20, - maxLimit: 10000, - sort: SortingOrder.Asc, - }); + const response = await controller.getAccountNFTTransfers( + address, + "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe36A", + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + { sort: SortingOrder.Asc } + ); expect(response).toEqual({ status: ResponseStatus.OK, message: ResponseMessage.OK, diff --git a/packages/api/src/api/account/account.controller.ts b/packages/api/src/api/account/account.controller.ts index 6c77e72f2a..507875b9c9 100644 --- a/packages/api/src/api/account/account.controller.ts +++ b/packages/api/src/api/account/account.controller.ts @@ -7,6 +7,7 @@ import { TransactionService } from "../../transaction/transaction.service"; import { TransferService } from "../../transfer/transfer.service"; import { BalanceService } from "../../balance/balance.service"; import { PagingOptionsWithMaxItemsLimitDto } from "../dtos/common/pagingOptionsWithMaxItemsLimit.dto"; +import { SortingOptionsDto } from "../dtos/common/sortingOptions.dto"; import { ParseLimitedIntPipe } from "../../common/pipes/parseLimitedInt.pipe"; import { ParseAddressPipe } from "../../common/pipes/parseAddress.pipe"; import { ParseTransactionHashPipe } from "../../common/pipes/parseTransactionHash.pipe"; @@ -50,6 +51,7 @@ export class AccountController { public async getAccountTransactions( @Query("address", new ParseAddressPipe()) address: string, @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + @Query() sortingOptions: SortingOptionsDto, @Query("startblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) startBlock?: number, @Query("endblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) endBlock?: number ): Promise { @@ -59,6 +61,7 @@ export class AccountController { startBlock, endBlock, ...pagingOptions, + ...sortingOptions, }), ]); const transactionsList = transactions.map((transaction) => mapTransactionListItem(transaction, lastBlockNumber)); @@ -79,6 +82,7 @@ export class AccountController { ) transactionHash: string, @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + @Query() sortingOptions: SortingOptionsDto, @Query("startblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) startBlock?: number, @Query("endblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) endBlock?: number ): Promise { @@ -88,6 +92,7 @@ export class AccountController { startBlock, endBlock, ...pagingOptions, + ...sortingOptions, }); const internalTransactionsList = transfers.map((transfer) => mapInternalTransactionListItem(transfer)); return { @@ -107,6 +112,7 @@ export class AccountController { ) contractAddress: string, @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + @Query() sortingOptions: SortingOptionsDto, @Query("startblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) startBlock?: number, @Query("endblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) endBlock?: number ): Promise { @@ -119,6 +125,7 @@ export class AccountController { startBlock, endBlock, ...pagingOptions, + ...sortingOptions, }), ]); const transfersList = transfers.map((transfer) => mapTransferListItem(transfer, lastBlockNumber)); @@ -139,6 +146,7 @@ export class AccountController { ) contractAddress: string, @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + @Query() sortingOptions: SortingOptionsDto, @Query("startblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) startBlock?: number, @Query("endblock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) endBlock?: number ): Promise { @@ -151,6 +159,7 @@ export class AccountController { startBlock, endBlock, ...pagingOptions, + ...sortingOptions, }), ]); const transfersList = transfers.map((transfer) => mapTransferListItem(transfer, lastBlockNumber)); diff --git a/packages/api/src/api/api.controller.spec.ts b/packages/api/src/api/api.controller.spec.ts index e4f8161e98..bf6968c6c4 100644 --- a/packages/api/src/api/api.controller.spec.ts +++ b/packages/api/src/api/api.controller.spec.ts @@ -71,48 +71,56 @@ describe("ApiController", () => { describe("getAccountTransactions", () => { it("returns null as it is defined only to appear in docs and cannot be called", async () => { - const result = await controller.getAccountTransactions({ - page: 1, - offset: 10, - sort: SortingOrder.Desc, - maxLimit: 10000, - }); + const result = await controller.getAccountTransactions( + { + page: 1, + offset: 10, + maxLimit: 10000, + }, + { sort: SortingOrder.Desc } + ); expect(result).toBe(null); }); }); describe("getAccountInternalTransactions", () => { it("returns null as it is defined only to appear in docs and cannot be called", async () => { - const result = await controller.getAccountInternalTransactions({ - page: 1, - offset: 10, - sort: SortingOrder.Desc, - maxLimit: 10000, - }); + const result = await controller.getAccountInternalTransactions( + { + page: 1, + offset: 10, + maxLimit: 10000, + }, + { sort: SortingOrder.Desc } + ); expect(result).toBe(null); }); }); describe("getAccountTokenTransfers", () => { it("returns null as it is defined only to appear in docs and cannot be called", async () => { - const result = await controller.getAccountTokenTransfers({ - page: 1, - offset: 10, - sort: SortingOrder.Desc, - maxLimit: 10000, - }); + const result = await controller.getAccountTokenTransfers( + { + page: 1, + offset: 10, + maxLimit: 10000, + }, + { sort: SortingOrder.Desc } + ); expect(result).toBe(null); }); }); describe("getAccountNFTTransfers", () => { it("returns null as it is defined only to appear in docs and cannot be called", async () => { - const result = await controller.getAccountNFTTransfers({ - page: 1, - offset: 10, - sort: SortingOrder.Desc, - maxLimit: 10000, - }); + const result = await controller.getAccountNFTTransfers( + { + page: 1, + offset: 10, + maxLimit: 10000, + }, + { sort: SortingOrder.Desc } + ); expect(result).toBe(null); }); }); @@ -158,4 +166,15 @@ describe("ApiController", () => { expect(result).toBe(null); }); }); + + describe("getLogs", () => { + it("returns null as it is defined only to appear in docs and cannot be called", async () => { + const result = await controller.getLogs({ + page: 1, + offset: 10, + maxLimit: 10000, + }); + expect(result).toBe(null); + }); + }); }); diff --git a/packages/api/src/api/api.controller.ts b/packages/api/src/api/api.controller.ts index 6fa050519c..99932064fb 100644 --- a/packages/api/src/api/api.controller.ts +++ b/packages/api/src/api/api.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Query, Req, Next, UseFilters } from "@nestjs/common"; import { ApiTags, ApiOkResponse, ApiExcludeEndpoint, ApiQuery, ApiExtraModels, ApiOperation } from "@nestjs/swagger"; import { Request, NextFunction } from "express"; import { PagingOptionsWithMaxItemsLimitDto } from "./dtos/common/pagingOptionsWithMaxItemsLimit.dto"; +import { SortingOptionsDto } from "./dtos/common/sortingOptions.dto"; import { ContractAbiResponseDto } from "./dtos/contract/contractAbiResponse.dto"; import { ContractCreationResponseDto, ContractCreationInfoDto } from "./dtos/contract/contractCreationResponse.dto"; import { ContractSourceCodeResponseDto } from "./dtos/contract/contractSourceCodeResponse.dto"; @@ -28,6 +29,7 @@ import { ApiRequestQuery, ApiModule } from "./types"; import { ParseModulePipe } from "./pipes/parseModule.pipe"; import { ParseActionPipe } from "./pipes/parseAction.pipe"; import { ApiExceptionFilter } from "./exceptionFilter"; +import { LogsResponseDto, LogApiDto } from "./dtos/log/logs.dto"; @Controller("") export class ApiController { @@ -167,7 +169,9 @@ export class ApiController { }) public async getAccountTransactions( // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Query() sortingOptions: SortingOptionsDto ): Promise { return null; } @@ -208,7 +212,9 @@ export class ApiController { }) public async getAccountInternalTransactions( // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Query() sortingOptions: SortingOptionsDto ): Promise { return null; } @@ -308,7 +314,9 @@ export class ApiController { }) public async getAccountTokenTransfers( // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Query() sortingOptions: SortingOptionsDto ): Promise { return null; } @@ -349,7 +357,9 @@ export class ApiController { }) public async getAccountNFTTransfers( // eslint-disable-next-line @typescript-eslint/no-unused-vars - @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Query() sortingOptions: SortingOptionsDto ): Promise { return null; } @@ -414,4 +424,39 @@ export class ApiController { public async getBlockRewards(): Promise { return null; } + + @ApiTags("Logs API") + @Get("api?module=logs&action=getLogs") + @ApiOperation({ summary: "Retrieve the event logs for an address, with optional filtering by block range" }) + @ApiQuery({ + name: "address", + description: "The address to filter logs by", + example: "0xFb7E0856e44Eff812A44A9f47733d7d55c39Aa28", + required: true, + }) + @ApiQuery({ + name: "fromBlock", + type: "integer", + description: "The integer block number to start searching for logs", + example: 12878196, + required: false, + }) + @ApiQuery({ + name: "toBlock", + type: "integer", + description: "The integer block number to stop searching for logs ", + example: 12879196, + required: false, + }) + @ApiExtraModels(LogApiDto) + @ApiOkResponse({ + description: "Returns event logs for an address", + type: LogsResponseDto, + }) + public async getLogs( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto + ): Promise { + return null; + } } diff --git a/packages/api/src/api/dtos/common/pagingOptions.dto.ts b/packages/api/src/api/dtos/common/pagingOptions.dto.ts index a8143a4626..a046d25201 100644 --- a/packages/api/src/api/dtos/common/pagingOptions.dto.ts +++ b/packages/api/src/api/dtos/common/pagingOptions.dto.ts @@ -1,7 +1,6 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsInt, IsOptional, Max, Min } from "class-validator"; -import { SortingOrder } from "../../../common/types"; export class PagingOptionsDto { @ApiPropertyOptional({ @@ -31,13 +30,4 @@ export class PagingOptionsDto { @Max(10000) @IsOptional() public readonly offset: number = 10; - - @ApiPropertyOptional({ - enum: SortingOrder, - default: SortingOrder.Desc, - description: "The sorting preference, use asc to sort by ascending and desc to sort by descending", - example: SortingOrder.Desc, - }) - @IsOptional() - public readonly sort: SortingOrder = SortingOrder.Desc; } diff --git a/packages/api/src/api/dtos/common/sortingOptions.dto.ts b/packages/api/src/api/dtos/common/sortingOptions.dto.ts new file mode 100644 index 0000000000..24f55d35d1 --- /dev/null +++ b/packages/api/src/api/dtos/common/sortingOptions.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsOptional } from "class-validator"; +import { SortingOrder } from "../../../common/types"; + +export class SortingOptionsDto { + @ApiPropertyOptional({ + enum: SortingOrder, + default: SortingOrder.Desc, + description: "The sorting preference, use asc to sort by ascending and desc to sort by descending", + example: SortingOrder.Desc, + }) + @IsOptional() + public readonly sort: SortingOrder = SortingOrder.Desc; +} diff --git a/packages/api/src/api/dtos/log/logs.dto.ts b/packages/api/src/api/dtos/log/logs.dto.ts new file mode 100644 index 0000000000..e7b5ff2197 --- /dev/null +++ b/packages/api/src/api/dtos/log/logs.dto.ts @@ -0,0 +1,90 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { ResponseBaseDto } from "../common/responseBase.dto"; + +export class LogApiDto { + @ApiProperty({ + type: String, + description: "The address of the contract that generated this log", + example: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35C", + }) + public readonly address: string; + + @ApiProperty({ + type: String, + isArray: true, + description: "The list of topics (indexed properties) for this log", + example: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000b7e2355b87ff9ae9b146ca6dcee9c02157937b01", + ], + }) + public readonly topics: string[]; + + @ApiProperty({ + type: String, + description: "The data included in this log", + example: "0x000000000000000000000000000000000000000000000000016345785d8a0000", + }) + public readonly data: string; + + @ApiProperty({ + type: String, + description: "The block height of the block including the transaction of this log", + example: "0xc48174", + }) + public readonly blockNumber: string; + + @ApiProperty({ + type: String, + description: "The timestamp when the log was created", + example: "0x65230fce", + }) + public readonly timeStamp: string; + + @ApiProperty({ + type: String, + description: "Gas price for the transaction of this log", + example: "0x60f9ce56", + }) + public readonly gasPrice: string; + + @ApiProperty({ + type: String, + description: "Gas used by the transaction of this log", + example: "0x60f9ce56", + }) + public readonly gasUsed: string; + + @ApiProperty({ + type: String, + description: "The index of this log across all logs in the entire block", + example: "0x1", + }) + public readonly logIndex: string; + + @ApiProperty({ + type: String, + description: "The transaction hash of the transaction of this log", + example: "0x5e018d2a81dbd1ef80ff45171dd241cb10670dcb091e324401ff8f52293841b0", + examples: ["0x5e018d2a81dbd1ef80ff45171dd241cb10670dcb091e324401ff8f52293841b0", null], + nullable: true, + }) + public readonly transactionHash?: string; + + @ApiProperty({ + type: String, + description: "The index of the transaction in the block of the transaction of this log", + example: "0x1", + }) + public readonly transactionIndex: string; +} + +export class LogsResponseDto extends ResponseBaseDto { + @ApiProperty({ + description: "Logs for an address", + type: LogApiDto, + isArray: true, + }) + public readonly result: LogApiDto[]; +} diff --git a/packages/api/src/api/log/log.controller.spec.ts b/packages/api/src/api/log/log.controller.spec.ts new file mode 100644 index 0000000000..2d00756b71 --- /dev/null +++ b/packages/api/src/api/log/log.controller.spec.ts @@ -0,0 +1,110 @@ +import { Test } from "@nestjs/testing"; +import { mock } from "jest-mock-extended"; +import { LogService } from "../../log/log.service"; +import { Logger } from "@nestjs/common"; +import { LogController } from "./log.controller"; + +jest.mock("../mappers/logMapper", () => ({ + ...jest.requireActual("../mappers/logMapper"), + mapLogListItem: jest.fn().mockImplementation((logs) => logs), +})); + +describe("LogController", () => { + let controller: LogController; + let logServiceMock: LogService; + + const address = "address"; + beforeEach(async () => { + logServiceMock = mock({ + findLogs: jest.fn().mockResolvedValue([ + { + logIndex: 1, + }, + { + logIndex: 2, + }, + ]), + }); + + const module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { + provide: LogService, + useValue: logServiceMock, + }, + ], + }).compile(); + module.useLogger(mock()); + + controller = module.get(LogController); + }); + + describe("getLogs", () => { + it("calls logs service with specified filter and paging params", async () => { + await controller.getLogs( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + 0, + 10 + ); + expect(logServiceMock.findLogs).toBeCalledTimes(1); + expect(logServiceMock.findLogs).toBeCalledWith({ + address, + fromBlock: 0, + toBlock: 10, + page: 2, + offset: 20, + maxLimit: 10000, + }); + }); + + it("returns ok response and logs list when logs are found", async () => { + const response = await controller.getLogs( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + 0, + 10 + ); + expect(response).toEqual({ + message: "OK", + result: [ + { + logIndex: 1, + }, + { + logIndex: 2, + }, + ], + status: "1", + }); + }); + + it("returns not ok response and empty logs list when logs are not found", async () => { + (logServiceMock.findLogs as jest.Mock).mockResolvedValueOnce([]); + const response = await controller.getLogs( + address, + { + page: 2, + offset: 20, + maxLimit: 10000, + }, + 0, + 10 + ); + expect(response).toEqual({ + message: "No record found", + result: [], + status: "0", + }); + }); + }); +}); diff --git a/packages/api/src/api/log/log.controller.ts b/packages/api/src/api/log/log.controller.ts new file mode 100644 index 0000000000..f68fc6692d --- /dev/null +++ b/packages/api/src/api/log/log.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Query, UseFilters } from "@nestjs/common"; +import { ApiTags, ApiExcludeController } from "@nestjs/swagger"; +import { PagingOptionsWithMaxItemsLimitDto } from "../dtos/common/pagingOptionsWithMaxItemsLimit.dto"; +import { ParseAddressPipe } from "../../common/pipes/parseAddress.pipe"; +import { ParseLimitedIntPipe } from "../../common/pipes/parseLimitedInt.pipe"; +import { ResponseStatus, ResponseMessage } from "../dtos/common/responseBase.dto"; +import { ApiExceptionFilter } from "../exceptionFilter"; +import { LogsResponseDto } from "../dtos/log/logs.dto"; +import { LogService } from "../../log/log.service"; +import { mapLogListItem } from "../mappers/logMapper"; + +const entityName = "logs"; + +@ApiExcludeController() +@ApiTags(entityName) +@Controller(`api/${entityName}`) +@UseFilters(ApiExceptionFilter) +export class LogController { + constructor(private readonly logService: LogService) {} + + @Get("/getLogs") + public async getLogs( + @Query("address", new ParseAddressPipe({ errorMessage: "Error! Invalid address format" })) + address: string, + @Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto, + @Query("fromBlock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) fromBlock?: number, + @Query("toBlock", new ParseLimitedIntPipe({ min: 0, isOptional: true })) toBlock?: number + ): Promise { + const logs = await this.logService.findLogs({ + address, + fromBlock, + toBlock, + ...pagingOptions, + }); + const logsList = logs.map((log) => mapLogListItem(log)); + return { + status: logsList.length ? ResponseStatus.OK : ResponseStatus.NOTOK, + message: logsList.length ? ResponseMessage.OK : ResponseMessage.NO_RECORD_FOUND, + result: logsList, + }; + } +} diff --git a/packages/api/src/api/log/log.module.ts b/packages/api/src/api/log/log.module.ts new file mode 100644 index 0000000000..de770521ff --- /dev/null +++ b/packages/api/src/api/log/log.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { LogController } from "./log.controller"; +import { LogModule } from "../../log/log.module"; + +@Module({ + imports: [HttpModule, LogModule], + controllers: [LogController], +}) +export class ApiLogModule {} diff --git a/packages/api/src/api/mappers/logMapper.spec.ts b/packages/api/src/api/mappers/logMapper.spec.ts new file mode 100644 index 0000000000..1d1a2c1241 --- /dev/null +++ b/packages/api/src/api/mappers/logMapper.spec.ts @@ -0,0 +1,77 @@ +import { mock } from "jest-mock-extended"; +import { mapLogListItem } from "./logMapper"; +import { Log } from "../../log/log.entity"; +import { Transaction } from "../../transaction/entities/transaction.entity"; +import { TransactionReceipt } from "../../transaction/entities/transactionReceipt.entity"; + +describe("mapLogListItem", () => { + let log: Log = { + address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", + data: "0x123", + blockNumber: 10, + transactionHash: "0x4ffd22d986913d33927a392fe4319bcd2b62f3afe1c15a2c59f77fc2cc4c20a9", + transactionIndex: 2, + logIndex: 1, + timestamp: new Date("2023-10-08T20:23:42.988Z"), + transaction: mock({ + gasPrice: "123", + transactionReceipt: mock({ + gasUsed: "234", + }), + }), + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000c45a4b3b698f21f88687548e7f5a80df8b99d93d", + ], + } as unknown as Log; + + it("returns mapped log entity", () => { + const result = mapLogListItem(log); + expect(result).toEqual({ + address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", + blockNumber: "0xa", + data: "0x123", + gasPrice: "0x7b", + gasUsed: "0xea", + logIndex: "0x1", + timeStamp: "0x65230fce", + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000c45a4b3b698f21f88687548e7f5a80df8b99d93d", + ], + transactionHash: "0x4ffd22d986913d33927a392fe4319bcd2b62f3afe1c15a2c59f77fc2cc4c20a9", + transactionIndex: "0x2", + }); + }); + + describe("when there is no transaction for the log", () => { + beforeEach(() => { + log = { + ...log, + transaction: undefined, + } as Log; + }); + + it("returns mapped log entity with no transaction values", () => { + const result = mapLogListItem(log); + expect(result).toEqual({ + address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", + blockNumber: "0xa", + data: "0x123", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x1", + timeStamp: "0x65230fce", + topics: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000c45a4b3b698f21f88687548e7f5a80df8b99d93d", + ], + transactionHash: "0x4ffd22d986913d33927a392fe4319bcd2b62f3afe1c15a2c59f77fc2cc4c20a9", + transactionIndex: "0x2", + }); + }); + }); +}); diff --git a/packages/api/src/api/mappers/logMapper.ts b/packages/api/src/api/mappers/logMapper.ts new file mode 100644 index 0000000000..d96ae5df24 --- /dev/null +++ b/packages/api/src/api/mappers/logMapper.ts @@ -0,0 +1,17 @@ +import { dateToTimestamp, numberToHex, parseIntToHex } from "../../common/utils"; +import { Log } from "../../log/log.entity"; + +export const mapLogListItem = (log: Log) => { + return { + address: log.address, + topics: log.topics, + data: log.data, + blockNumber: numberToHex(log.blockNumber), + timeStamp: numberToHex(dateToTimestamp(log.timestamp)), + gasPrice: parseIntToHex(log.transaction?.gasPrice), + gasUsed: parseIntToHex(log.transaction?.transactionReceipt?.gasUsed), + logIndex: numberToHex(log.logIndex), + transactionHash: log.transactionHash, + transactionIndex: numberToHex(log.transactionIndex), + }; +}; diff --git a/packages/api/src/api/types.ts b/packages/api/src/api/types.ts index 039eb00ac3..27a57afe97 100644 --- a/packages/api/src/api/types.ts +++ b/packages/api/src/api/types.ts @@ -9,6 +9,7 @@ export enum ApiModule { Contract = "contract", Transaction = "transaction", Block = "block", + Logs = "logs", } export enum ApiAccountAction { @@ -38,11 +39,16 @@ export enum ApiBlockAction { GetBlockRewards = "getblockreward", } +export enum ApiLogsAction { + getLogs = "getLogs", +} + export const apiActionsMap = { [ApiModule.Account]: Object.values(ApiAccountAction) as string[], [ApiModule.Contract]: Object.values(ApiContractAction) as string[], [ApiModule.Transaction]: Object.values(ApiTransactionAction) as string[], [ApiModule.Block]: Object.values(ApiBlockAction) as string[], + [ApiModule.Logs]: Object.values(ApiLogsAction) as string[], }; type ContractFunctionInput = { diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 84d0050a42..fccac26cbf 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -7,6 +7,7 @@ import { ApiBlockModule } from "./api/block/block.module"; import { ApiAccountModule } from "./api/account/account.module"; import { ApiContractModule } from "./api/contract/contract.module"; import { ApiTransactionModule } from "./api/transaction/transaction.module"; +import { ApiLogModule } from "./api/log/log.module"; import { TokenModule } from "./token/token.module"; import { BatchModule } from "./batch/batch.module"; import { BlockModule } from "./block/block.module"; @@ -34,7 +35,7 @@ const { disableExternalAPI } = config(); }), ApiModule, ApiContractModule, - ...(disableExternalAPI ? [] : [ApiBlockModule, ApiAccountModule, ApiTransactionModule]), + ...(disableExternalAPI ? [] : [ApiBlockModule, ApiAccountModule, ApiTransactionModule, ApiLogModule]), TokenModule, AddressModule, BalanceModule, diff --git a/packages/api/src/common/utils.spec.ts b/packages/api/src/common/utils.spec.ts index 394e2c6850..cbb5aac16b 100644 --- a/packages/api/src/common/utils.spec.ts +++ b/packages/api/src/common/utils.spec.ts @@ -3,7 +3,15 @@ import { MoreThanOrEqual, LessThanOrEqual, Between, SelectQueryBuilder } from "t import * as paginator from "nestjs-typeorm-paginate"; import { hexTransformer } from "./transformers/hex.transformer"; import { BaseEntity } from "../common/entities/base.entity"; -import { buildDateFilter, paginate, formatHexAddress, getMethodId, dateToTimestamp } from "./utils"; +import { + buildDateFilter, + paginate, + formatHexAddress, + getMethodId, + dateToTimestamp, + numberToHex, + parseIntToHex, +} from "./utils"; import { IPaginationOptions } from "./types"; jest.mock("nestjs-typeorm-paginate"); @@ -363,4 +371,36 @@ describe("utils", () => { expect(dateToTimestamp(new Date("2022-11-21T18:16:51.000Z"))).toBe(1669054611); }); }); + + describe("numberToHex", () => { + it("returns hex str for the specified number", () => { + expect(numberToHex(1000)).toBe("0x3e8"); + }); + + it("returns 0x if the specified number is null", () => { + expect(numberToHex(null)).toBe("0x"); + }); + + it("returns 0x if the specified number is undefined", () => { + expect(numberToHex(undefined)).toBe("0x"); + }); + }); + + describe("parseIntToHex", () => { + it("returns hex str for the specified number as a string", () => { + expect(parseIntToHex("1000")).toBe("0x3e8"); + }); + + it("returns 0x if the specified number is null", () => { + expect(parseIntToHex(null)).toBe("0x"); + }); + + it("returns 0x if the specified number is undefined", () => { + expect(parseIntToHex(undefined)).toBe("0x"); + }); + + it("returns 0x if the specified number is not valid int", () => { + expect(parseIntToHex("azxf")).toBe("0x"); + }); + }); }); diff --git a/packages/api/src/common/utils.ts b/packages/api/src/common/utils.ts index 2fec873c09..c65e3c7e43 100644 --- a/packages/api/src/common/utils.ts +++ b/packages/api/src/common/utils.ts @@ -109,3 +109,15 @@ export const formatHexAddress = (address: string) => hexTransformer.from(hexTran export const getMethodId = (data: string) => (data.length > 10 ? data.substring(0, 10) : "0x"); export const dateToTimestamp = (date: Date) => Math.floor(date.getTime() / 1000); + +export const numberToHex = (num: number) => (num != null ? `0x${num.toString(16)}` : "0x"); + +export const parseIntToHex = (numStr: string) => { + if (numStr != null) { + const parsedInt = parseInt(numStr, 10); + if (!Number.isNaN(parsedInt)) { + return numberToHex(parsedInt); + } + } + return "0x"; +}; diff --git a/packages/api/src/log/log.entity.ts b/packages/api/src/log/log.entity.ts index 64a591e3ce..a132417318 100644 --- a/packages/api/src/log/log.entity.ts +++ b/packages/api/src/log/log.entity.ts @@ -1,9 +1,10 @@ -import { Entity, Column, PrimaryColumn, Index } from "typeorm"; +import { Entity, Column, Index, ManyToOne, JoinColumn, PrimaryColumn } from "typeorm"; import { BaseEntity } from "../common/entities/base.entity"; import { bigIntNumberTransformer } from "../common/transformers/bigIntNumber.transformer"; import { normalizeAddressTransformer } from "../common/transformers/normalizeAddress.transformer"; import { hexTransformer } from "../common/transformers/hex.transformer"; import { hexArrayTransformer } from "../common/transformers/hexArray.transformer"; +import { Transaction } from "../transaction/entities/transaction.entity"; @Entity({ name: "logs" }) @Index(["blockNumber", "logIndex"]) @@ -23,6 +24,10 @@ export class Log extends BaseEntity { @Column({ type: "bigint", transformer: bigIntNumberTransformer }) public readonly blockNumber: number; + @ManyToOne(() => Transaction) + @JoinColumn({ name: "transactionHash" }) + public readonly transaction?: Transaction; + @Column({ type: "bytea", nullable: true, transformer: hexTransformer }) public readonly transactionHash?: string; diff --git a/packages/api/src/log/log.service.spec.ts b/packages/api/src/log/log.service.spec.ts index 6ae4809181..96c7017b5b 100644 --- a/packages/api/src/log/log.service.spec.ts +++ b/packages/api/src/log/log.service.spec.ts @@ -1,10 +1,10 @@ import { Test, TestingModule } from "@nestjs/testing"; import { mock } from "jest-mock-extended"; import { getRepositoryToken } from "@nestjs/typeorm"; -import { Repository, SelectQueryBuilder } from "typeorm"; +import { Repository, SelectQueryBuilder, MoreThanOrEqual, LessThanOrEqual } from "typeorm"; import { Pagination, IPaginationMeta } from "nestjs-typeorm-paginate"; import * as utils from "../common/utils"; -import { LogService, FilterLogsOptions } from "./log.service"; +import { LogService, FilterLogsOptions, FilterLogsByAddressOptions } from "./log.service"; import { Log } from "./log.entity"; jest.mock("../common/utils"); @@ -100,4 +100,117 @@ describe("LogService", () => { expect(result).toBe(paginationResult); }); }); + + describe("findLogs", () => { + let queryBuilderMock; + let filterOptions: FilterLogsByAddressOptions; + + beforeEach(() => { + queryBuilderMock = mock>(); + (repositoryMock.createQueryBuilder as jest.Mock).mockReturnValue(queryBuilderMock); + + filterOptions = { + address: "address", + }; + }); + + describe("when address filter options is specified", () => { + beforeEach(() => { + jest.spyOn(queryBuilderMock, "getMany").mockResolvedValue([ + { + logIndex: 1, + }, + { + logIndex: 2, + }, + ]); + }); + + it("creates query builder with proper params", async () => { + await service.findLogs(filterOptions); + expect(repositoryMock.createQueryBuilder).toHaveBeenCalledTimes(1); + expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("log"); + }); + + it("joins transaction and transactionReceipt records to the logs", async () => { + await service.findLogs(filterOptions); + expect(queryBuilderMock.leftJoin).toBeCalledTimes(2); + expect(queryBuilderMock.leftJoin).toHaveBeenCalledWith("log.transaction", "transaction"); + expect(queryBuilderMock.leftJoin).toHaveBeenCalledWith("transaction.transactionReceipt", "transactionReceipt"); + }); + + it("selects only needed fields from joined records", async () => { + await service.findLogs(filterOptions); + expect(queryBuilderMock.addSelect).toBeCalledTimes(1); + expect(queryBuilderMock.addSelect).toHaveBeenCalledWith(["transaction.gasPrice", "transactionReceipt.gasUsed"]); + }); + + it("filters logs by address", async () => { + await service.findLogs(filterOptions); + expect(queryBuilderMock.where).toBeCalledTimes(1); + expect(queryBuilderMock.where).toHaveBeenCalledWith({ + address: filterOptions.address, + }); + }); + + describe("when fromBlock filter is specified", () => { + it("adds blockNumber filter", async () => { + await service.findLogs({ + ...filterOptions, + fromBlock: 10, + }); + expect(queryBuilderMock.andWhere).toBeCalledTimes(1); + expect(queryBuilderMock.andWhere).toHaveBeenCalledWith({ + blockNumber: MoreThanOrEqual(10), + }); + }); + }); + + describe("when toBlock filter is specified", () => { + it("adds toBlock filter", async () => { + await service.findLogs({ + ...filterOptions, + toBlock: 10, + }); + expect(queryBuilderMock.andWhere).toBeCalledTimes(1); + expect(queryBuilderMock.andWhere).toHaveBeenCalledWith({ + blockNumber: LessThanOrEqual(10), + }); + }); + }); + + it("sets offset and limit", async () => { + await service.findLogs({ + ...filterOptions, + page: 2, + offset: 100, + }); + expect(queryBuilderMock.offset).toBeCalledTimes(1); + expect(queryBuilderMock.offset).toHaveBeenCalledWith(100); + expect(queryBuilderMock.limit).toBeCalledTimes(1); + expect(queryBuilderMock.limit).toHaveBeenCalledWith(100); + }); + + it("sorts by blockNumber asc and logIndex asc", async () => { + await service.findLogs(filterOptions); + expect(queryBuilderMock.orderBy).toBeCalledTimes(1); + expect(queryBuilderMock.orderBy).toHaveBeenCalledWith("log.blockNumber", "ASC"); + expect(queryBuilderMock.addOrderBy).toBeCalledTimes(1); + expect(queryBuilderMock.addOrderBy).toHaveBeenCalledWith("log.logIndex", "ASC"); + }); + + it("executes query and returns transfers list", async () => { + const result = await service.findLogs(filterOptions); + expect(result).toEqual([ + { + logIndex: 1, + }, + { + logIndex: 2, + }, + ]); + expect(queryBuilderMock.getMany).toBeCalledTimes(1); + }); + }); + }); }); diff --git a/packages/api/src/log/log.service.ts b/packages/api/src/log/log.service.ts index f9afbbebad..6f65dabba1 100644 --- a/packages/api/src/log/log.service.ts +++ b/packages/api/src/log/log.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Repository, MoreThanOrEqual, LessThanOrEqual } from "typeorm"; import { Pagination } from "nestjs-typeorm-paginate"; import { IPaginationOptions } from "../common/types"; import { paginate } from "../common/utils"; @@ -11,6 +11,14 @@ export interface FilterLogsOptions { address?: string; } +export interface FilterLogsByAddressOptions { + address: string; + fromBlock?: number; + toBlock?: number; + page?: number; + offset?: number; +} + @Injectable() export class LogService { constructor( @@ -28,4 +36,34 @@ export class LogService { queryBuilder.addOrderBy("log.logIndex", "ASC"); return await paginate(queryBuilder, paginationOptions); } + + public async findLogs({ + address, + fromBlock, + toBlock, + page = 1, + offset = 10, + }: FilterLogsByAddressOptions): Promise { + const queryBuilder = this.logRepository.createQueryBuilder("log"); + queryBuilder.leftJoin("log.transaction", "transaction"); + queryBuilder.leftJoin("transaction.transactionReceipt", "transactionReceipt"); + queryBuilder.addSelect(["transaction.gasPrice", "transactionReceipt.gasUsed"]); + queryBuilder.where({ address }); + if (fromBlock !== undefined) { + queryBuilder.andWhere({ + blockNumber: MoreThanOrEqual(fromBlock), + }); + } + if (toBlock !== undefined) { + queryBuilder.andWhere({ + blockNumber: LessThanOrEqual(toBlock), + }); + } + + queryBuilder.offset((page - 1) * offset); + queryBuilder.limit(offset); + queryBuilder.orderBy("log.blockNumber", "ASC"); + queryBuilder.addOrderBy("log.logIndex", "ASC"); + return await queryBuilder.getMany(); + } } diff --git a/packages/api/test/log-api.e2e-spec.ts b/packages/api/test/log-api.e2e-spec.ts new file mode 100644 index 0000000000..d97d124d5c --- /dev/null +++ b/packages/api/test/log-api.e2e-spec.ts @@ -0,0 +1,373 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { getRepositoryToken } from "@nestjs/typeorm"; +import * as request from "supertest"; +import { Repository } from "typeorm"; +import { BatchDetails } from "../src/batch/batchDetails.entity"; +import { BlockDetail } from "../src/block/blockDetail.entity"; +import { Log } from "../src/log/log.entity"; +import { Transaction } from "../src/transaction/entities/transaction.entity"; +import { TransactionReceipt } from "../src/transaction/entities/transactionReceipt.entity"; +import { AppModule } from "../src/app.module"; +import { configureApp } from "../src/configureApp"; + +describe("Logs API (e2e)", () => { + let app: INestApplication; + let transactionRepository: Repository; + let transactionReceiptRepository: Repository; + let blockRepository: Repository; + let batchRepository: Repository; + let logRepository: Repository; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication({ logger: false }); + configureApp(app); + await app.init(); + + transactionRepository = app.get>(getRepositoryToken(Transaction)); + transactionReceiptRepository = app.get>(getRepositoryToken(TransactionReceipt)); + blockRepository = app.get>(getRepositoryToken(BlockDetail)); + batchRepository = app.get>(getRepositoryToken(BatchDetails)); + logRepository = app.get>(getRepositoryToken(Log)); + + await batchRepository.insert({ + number: 0, + timestamp: new Date("2022-11-10T14:44:08.000Z"), + l1TxCount: 10, + l2TxCount: 20, + l1GasPrice: "10000000", + l2FairGasPrice: "20000000", + commitTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e21", + proveTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e22", + executeTxHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e23", + }); + + await blockRepository.insert({ + number: 0, + hash: "0x4f86d6647711915ac90e5ef69c29845946f0a55b3feaa0488aece4a359f79cb1", + timestamp: new Date("2022-11-10T14:44:08.000Z"), + gasLimit: "0", + gasUsed: "0", + baseFeePerGas: "100000000", + extraData: "0x", + l1TxCount: 1, + l2TxCount: 1, + l1BatchNumber: 0, + miner: "0x0000000000000000000000000000000000000000", + }); + + await blockRepository.insert({ + number: 1, + hash: "0x4f86d6647711915ac90e5ef69c29845946f0a55b3feaa0488aece4a359f79cb1", + timestamp: new Date("2022-11-10T14:44:08.000Z"), + gasLimit: "0", + gasUsed: "0", + baseFeePerGas: "100000000", + extraData: "0x", + l1TxCount: 1, + l2TxCount: 1, + l1BatchNumber: 0, + miner: "0x0000000000000000000000000000000000000000", + }); + + await transactionRepository.insert({ + to: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35C", + from: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35C", + data: "0x000000000000000000000000000000000000000000000000016345785d8a0000", + value: "0x2386f26fc10000", + fee: "0x2386f26fc10000", + nonce: 42, + blockHash: "0x4f86d6647711915ac90e5ef69c29845946f0a55b3feaa0488aece4a359f79cb1", + isL1Originated: true, + hash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: 1, + blockNumber: 1, + receivedAt: "2010-11-21T18:16:00.000Z", + l1BatchNumber: 0, + receiptStatus: 0, + gasLimit: "1000000", + gasPrice: "100", + }); + + await transactionReceiptRepository.insert({ + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + from: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35C", + status: 1, + gasUsed: "900000", + cumulativeGasUsed: "1100000", + contractAddress: "0xc7e0220d02d549c4846A6EC31D89C3B670Ebe35E", + }); + + for (let i = 0; i < 4; i++) { + await logRepository.insert({ + address: "0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + data: "0x", + blockNumber: i < 2 ? 0 : 1, + transactionIndex: 1, + logIndex: i + 1, + timestamp: "2022-11-21T18:16:51.000Z", + transactionHash: i % 2 ? "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20" : null, + }); + } + }); + + afterAll(async () => { + await logRepository.delete({}); + await transactionReceiptRepository.delete({}); + await transactionRepository.delete({}); + await blockRepository.delete({}); + await batchRepository.delete({}); + await app.close(); + }); + + describe("/api?module=logs&action=getLogs GET", () => { + it("returns HTTP 200 and no records found response when no logs found", () => { + return request(app.getHttpServer()) + .get(`/api?module=logs&action=getLogs&address=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb66`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + status: "0", + message: "No record found", + result: [], + }) + ); + }); + + it("returns HTTP 200 and logs list when logs found", () => { + return request(app.getHttpServer()) + .get(`/api?module=logs&action=getLogs&address=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x0", + data: "0x", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x1", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: null, + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x0", + data: "0x", + gasPrice: "0x64", + gasUsed: "0xdbba0", + logIndex: "0x2", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x3", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: null, + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x64", + gasUsed: "0xdbba0", + logIndex: "0x4", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: "0x1", + }, + ], + status: "1", + }) + ); + }); + + it("returns HTTP 200 and logs list for the specified paging params", () => { + return request(app.getHttpServer()) + .get(`/api?module=logs&action=getLogs&address=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67&offset=2&page=2`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x3", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: null, + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x64", + gasUsed: "0xdbba0", + logIndex: "0x4", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: "0x1", + }, + ], + status: "1", + }) + ); + }); + + it("returns HTTP 200 and logs list for the specified toBlock filter param", () => { + return request(app.getHttpServer()) + .get(`/api?module=logs&action=getLogs&address=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67&toBlock=0`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x0", + data: "0x", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x1", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: null, + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x0", + data: "0x", + gasPrice: "0x64", + gasUsed: "0xdbba0", + logIndex: "0x2", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: "0x1", + }, + ], + status: "1", + }) + ); + }); + + it("returns HTTP 200 and logs list for the specified fromBlock filter param", () => { + return request(app.getHttpServer()) + .get(`/api?module=logs&action=getLogs&address=0x91d0a23f34e535e44df8ba84c53a0945cf0eeb67&fromBlock=1`) + .expect(200) + .expect((res) => + expect(res.body).toStrictEqual({ + message: "OK", + result: [ + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x", + gasUsed: "0x", + logIndex: "0x3", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: null, + transactionIndex: "0x1", + }, + { + address: "0x91D0a23f34E535E44dF8ba84c53A0945CF0EEb67", + blockNumber: "0x1", + data: "0x", + gasPrice: "0x64", + gasUsed: "0xdbba0", + logIndex: "0x4", + timeStamp: "0x637bc093", + topics: [ + "0x290afdae231a3fc0bbae8b1af63698b0a1d79b21ad17df0342dfb952fe74f8e5", + "0x00000000000000000000000052312ad6f01657413b2eae9287f6b9adad93d5fe", + "0x01000121454160924d2d2547cb1eb843bf7a6dc8a406b2a5dd1b183d5221865c", + "0x0000000000000000000000000265d9a5af8af5fe070933e5e549d8fef08e09f4", + ], + transactionHash: "0x8a008b8dbbc18035e56370abb820e736b705d68d6ac12b203603db8d9ea87e20", + transactionIndex: "0x1", + }, + ], + status: "1", + }) + ); + }); + }); +}); diff --git a/packages/worker/src/entities/log.entity.ts b/packages/worker/src/entities/log.entity.ts index 5cc5a3ba15..182654d2a6 100644 --- a/packages/worker/src/entities/log.entity.ts +++ b/packages/worker/src/entities/log.entity.ts @@ -10,6 +10,7 @@ import { BaseEntity } from "./base.entity"; @Entity({ name: "logs" }) @Index(["address", "timestamp", "logIndex"]) @Index(["transactionHash", "timestamp", "logIndex"]) +@Index(["address", "blockNumber", "logIndex"]) export class Log extends BaseEntity { @PrimaryColumn({ generated: true, type: "bigint" }) public readonly number: number; diff --git a/packages/worker/src/migrations/1696860242519-AddLogIndexForAddress.ts b/packages/worker/src/migrations/1696860242519-AddLogIndexForAddress.ts new file mode 100644 index 0000000000..475a9d76f1 --- /dev/null +++ b/packages/worker/src/migrations/1696860242519-AddLogIndexForAddress.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddLogIndexForAddress1696860242519 implements MigrationInterface { + name = "AddLogIndexForAddress1696860242519"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_ebbb1251d0299f223e2d45f98f" ON "logs" ("address", "blockNumber", "logIndex") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_ebbb1251d0299f223e2d45f98f"`); + } +}