From aa3d25cc6c3f732e08d53bf86ce3291b3902b7a7 Mon Sep 17 00:00:00 2001 From: Marko Arambasic Date: Wed, 29 Jan 2025 21:44:06 +0100 Subject: [PATCH] fix: create proper token page with new api and add holders page --- .../dtos/balances}/balanceForHolder.dto.ts | 0 .../src/api/dtos/token/tokenOverview.dto.ts | 13 ++ .../api/src/balance/balance.service.spec.ts | 116 ++++++++++++++++++ packages/api/src/balance/balance.service.ts | 33 ++++- .../api/src/token/token.controller.spec.ts | 43 ++++++- packages/api/src/token/token.controller.ts | 26 +++- packages/app/src/components/Contract.vue | 25 ---- packages/app/src/components/Token.vue | 78 +++++------- .../components/token/MarketTokenInfoTable.vue | 107 ++++++++++++++++ .../token/OverviewTokenInfoTable.vue | 115 +++++++++++++++++ .../src/components/token/TokenHoldersList.vue | 24 ++-- packages/app/src/composables/common/Api.d.ts | 5 + .../app/src/composables/useTokenOverview.ts | 39 ++++++ packages/app/src/locales/en.json | 17 ++- packages/app/src/locales/uk.json | 17 ++- 15 files changed, 570 insertions(+), 88 deletions(-) rename packages/api/src/{balance => api/dtos/balances}/balanceForHolder.dto.ts (100%) create mode 100644 packages/api/src/api/dtos/token/tokenOverview.dto.ts create mode 100644 packages/app/src/components/token/MarketTokenInfoTable.vue create mode 100644 packages/app/src/components/token/OverviewTokenInfoTable.vue create mode 100644 packages/app/src/composables/useTokenOverview.ts diff --git a/packages/api/src/balance/balanceForHolder.dto.ts b/packages/api/src/api/dtos/balances/balanceForHolder.dto.ts similarity index 100% rename from packages/api/src/balance/balanceForHolder.dto.ts rename to packages/api/src/api/dtos/balances/balanceForHolder.dto.ts diff --git a/packages/api/src/api/dtos/token/tokenOverview.dto.ts b/packages/api/src/api/dtos/token/tokenOverview.dto.ts new file mode 100644 index 0000000000..46aa494289 --- /dev/null +++ b/packages/api/src/api/dtos/token/tokenOverview.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class TokenOverviewDto { + @ApiProperty({ type: Number, description: "Number of holders", example: "300" }) + public readonly holders: number; + + @ApiProperty({ + type: Number, + description: "Total supply", + example: "1000000000000000000000000000", + }) + public readonly maxTotalSupply: number; +} diff --git a/packages/api/src/balance/balance.service.spec.ts b/packages/api/src/balance/balance.service.spec.ts index 24b8352555..603f98b2db 100644 --- a/packages/api/src/balance/balance.service.spec.ts +++ b/packages/api/src/balance/balance.service.spec.ts @@ -231,6 +231,10 @@ describe("BalanceService", () => { (repositoryMock.createQueryBuilder as jest.Mock).mockReturnValueOnce(mainQueryBuilderMock); }); + afterEach(() => { + jest.resetAllMocks(); + }); + it("creates sub query builder with proper params", async () => { await service.getBalancesByAddresses(addresses, tokenAddress); expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("latest_balances"); @@ -324,6 +328,10 @@ describe("BalanceService", () => { (repositoryMock.createQueryBuilder as jest.Mock).mockReturnValueOnce(mainQueryBuilderMock); }); + afterEach(() => { + jest.resetAllMocks(); + }); + it("creates sub query builder with proper params", async () => { await service.getBalancesForTokenAddress(tokenAddress, pagingOptions); expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("latest_balances"); @@ -427,4 +435,112 @@ describe("BalanceService", () => { expect(result).toStrictEqual({ ...paginationResult, items: [] }); }); }); + + describe("getSumAndCountBalances", () => { + const subQuerySql = "subQuerySql"; + const tokenAddress = "0x91d0a23f34e535e44df8ba84c53a0945cf0eeb69"; + let subQueryBuilderMock; + let mainQueryBuilderMock; + + beforeEach(() => { + subQueryBuilderMock = mock>({ + getQuery: jest.fn().mockReturnValue(subQuerySql), + }); + mainQueryBuilderMock = mock>(); + (repositoryMock.createQueryBuilder as jest.Mock).mockReturnValueOnce(subQueryBuilderMock); + (repositoryMock.createQueryBuilder as jest.Mock).mockReturnValueOnce(mainQueryBuilderMock); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("creates sub query builder with proper params", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("latest_balances"); + }); + + it("selects required fields in the sub query", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(subQueryBuilderMock.select).toHaveBeenCalledTimes(1); + expect(subQueryBuilderMock.select).toHaveBeenCalledWith(`"tokenAddress"`); + expect(subQueryBuilderMock.addSelect).toHaveBeenCalledTimes(2); + expect(subQueryBuilderMock.addSelect).toHaveBeenCalledWith(`"address"`); + expect(subQueryBuilderMock.addSelect).toHaveBeenCalledWith(`MAX("blockNumber")`, "blockNumber"); + }); + + it("filters balances in the sub query", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(subQueryBuilderMock.where).toHaveBeenCalledTimes(1); + expect(subQueryBuilderMock.where).toHaveBeenCalledWith(`"tokenAddress" = :tokenAddress`); + }); + + it("groups by address and tokenAddress in the sub query", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(subQueryBuilderMock.groupBy).toHaveBeenCalledTimes(1); + expect(subQueryBuilderMock.groupBy).toHaveBeenCalledWith(`"tokenAddress"`); + expect(subQueryBuilderMock.addGroupBy).toHaveBeenCalledTimes(1); + expect(subQueryBuilderMock.addGroupBy).toHaveBeenCalledWith(`"address"`); + }); + + it("creates main query builder with proper params", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("balances"); + }); + + it("joins main query with the sub query", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(mainQueryBuilderMock.innerJoin).toHaveBeenCalledTimes(1); + expect(mainQueryBuilderMock.innerJoin).toHaveBeenCalledWith( + `(${subQuerySql})`, + "latest_balances", + `balances."tokenAddress" = latest_balances."tokenAddress" AND + balances."address" = latest_balances."address" AND + balances."blockNumber" = latest_balances."blockNumber"` + ); + }); + + it("sets query tokenAddress param", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledTimes(1); + expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledWith("tokenAddress", hexTransformer.to(tokenAddress)); + }); + + it("select count and sum", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(mainQueryBuilderMock.addSelect).toHaveBeenCalledTimes(2); + expect(mainQueryBuilderMock.addSelect).toHaveBeenNthCalledWith( + 1, + "SUM(CAST(balances.balance AS NUMERIC))", + "totalBalance" + ); + expect(mainQueryBuilderMock.addSelect).toHaveBeenNthCalledWith(2, "COUNT(balances.address)", "totalCount"); + }); + + it("sets query tokenAddress param", async () => { + await service.getSumAndCountBalances(tokenAddress); + expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledTimes(1); + expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledWith("tokenAddress", hexTransformer.to(tokenAddress)); + }); + + it("returns results", async () => { + mainQueryBuilderMock.getRawOne = jest.fn().mockResolvedValue({ + totalBalance: 1000, + totalCount: 2, + }); + const result = await service.getSumAndCountBalances(tokenAddress); + expect(result).toStrictEqual({ + holders: 2, + maxTotalSupply: 1000, + }); + }); + + it("returns empty results", async () => { + const result = await service.getSumAndCountBalances(tokenAddress); + expect(result).toStrictEqual({ + holders: 0, + maxTotalSupply: 0, + }); + }); + }); }); diff --git a/packages/api/src/balance/balance.service.ts b/packages/api/src/balance/balance.service.ts index 753b2e0ab5..bb445f95ef 100644 --- a/packages/api/src/balance/balance.service.ts +++ b/packages/api/src/balance/balance.service.ts @@ -4,9 +4,10 @@ import { Repository } from "typeorm"; import { Balance } from "./balance.entity"; import { Token } from "../token/token.entity"; import { hexTransformer } from "../common/transformers/hex.transformer"; -import { BalanceForHolderDto } from "./balanceForHolder.dto"; +import { BalanceForHolderDto } from "../api/dtos/balances/balanceForHolder.dto"; import { paginate } from "../common/utils"; import { IPaginationOptions, Pagination } from "nestjs-typeorm-paginate"; +import { TokenOverviewDto } from "src/api/dtos/token/tokenOverview.dto"; export interface TokenBalance { balance: string; @@ -139,4 +140,34 @@ export class BalanceService { }), }; } + + public async getSumAndCountBalances(tokenAddress: string): Promise { + const latestBalancesQuery = this.balanceRepository.createQueryBuilder("latest_balances"); + latestBalancesQuery.select(`"tokenAddress"`); + latestBalancesQuery.addSelect(`"address"`); + latestBalancesQuery.addSelect(`MAX("blockNumber")`, "blockNumber"); + latestBalancesQuery.where(`"tokenAddress" = :tokenAddress`); + latestBalancesQuery.groupBy(`"tokenAddress"`); + latestBalancesQuery.addGroupBy(`"address"`); + + const balancesQuery = this.balanceRepository.createQueryBuilder("balances"); + balancesQuery.innerJoin( + `(${latestBalancesQuery.getQuery()})`, + "latest_balances", + `balances."tokenAddress" = latest_balances."tokenAddress" AND + balances."address" = latest_balances."address" AND + balances."blockNumber" = latest_balances."blockNumber"` + ); + balancesQuery.setParameter("tokenAddress", hexTransformer.to(tokenAddress)); + balancesQuery.leftJoinAndSelect("balances.token", "token"); + balancesQuery.select("SUM(CAST(balances.balance AS NUMERIC))", "totalBalance"); + balancesQuery.addSelect("COUNT(balances.address)", "totalCount"); + + const result = await balancesQuery.getRawOne(); + + return { + maxTotalSupply: parseFloat(result?.totalBalance) || 0, + holders: parseInt(result?.totalCount) || 0, + }; + } } diff --git a/packages/api/src/token/token.controller.spec.ts b/packages/api/src/token/token.controller.spec.ts index 8f33f1b9b3..438357d32e 100644 --- a/packages/api/src/token/token.controller.spec.ts +++ b/packages/api/src/token/token.controller.spec.ts @@ -8,8 +8,9 @@ import { TransferService } from "../transfer/transfer.service"; import { Token } from "./token.entity"; import { Transfer } from "../transfer/transfer.entity"; import { PagingOptionsDto, PagingOptionsWithMaxItemsLimitDto } from "../common/dtos"; -import { BalanceForHolderDto } from "../balance/balanceForHolder.dto"; +import { BalanceForHolderDto } from "../api/dtos/balances/balanceForHolder.dto"; import { BalanceService } from "../balance/balance.service"; +import { TokenOverviewDto } from "src/api/dtos/token/tokenOverview.dto"; describe("TokenController", () => { const tokenAddress = "tokenAddress"; @@ -191,4 +192,44 @@ describe("TokenController", () => { }); }); }); + + describe("getTokenOverview", () => { + describe("when token exists", () => { + const tokenOverview = mock({ + holders: 2, + maxTotalSupply: 10000000000000000000000000000, + }); + beforeEach(() => { + (serviceMock.exists as jest.Mock).mockResolvedValueOnce(true); + (balanceServiceMock.getSumAndCountBalances as jest.Mock).mockResolvedValueOnce(tokenOverview); + }); + + it("queries overview with the specified options", async () => { + await controller.getTokenOverview(tokenAddress); + expect(balanceServiceMock.getSumAndCountBalances).toHaveBeenCalledTimes(1); + expect(balanceServiceMock.getSumAndCountBalances).toHaveBeenCalledWith(tokenAddress); + }); + + it("returns token overview", async () => { + const result = await controller.getTokenOverview(tokenAddress); + expect(result).toBe(tokenOverview); + }); + }); + + describe("when token does not exist", () => { + beforeEach(() => { + (serviceMock.exists as jest.Mock).mockResolvedValueOnce(false); + }); + + it("throws NotFoundException", async () => { + expect.assertions(1); + + try { + await controller.getTokenOverview(tokenAddress); + } catch (error) { + expect(error).toBeInstanceOf(NotFoundException); + } + }); + }); + }); }); diff --git a/packages/api/src/token/token.controller.ts b/packages/api/src/token/token.controller.ts index cbd0463fd3..8c92df9f08 100644 --- a/packages/api/src/token/token.controller.ts +++ b/packages/api/src/token/token.controller.ts @@ -20,7 +20,8 @@ import { ParseAddressPipe, ADDRESS_REGEX_PATTERN } from "../common/pipes/parseAd import { swagger } from "../config/featureFlags"; import { constants } from "../config/docs"; import { BalanceService } from "../balance/balance.service"; -import { BalanceForHolderDto } from "../balance/balanceForHolder.dto"; +import { BalanceForHolderDto } from "../api/dtos/balances/balanceForHolder.dto"; +import { TokenOverviewDto } from "src/api/dtos/token/tokenOverview.dto"; const entityName = "tokens"; @@ -138,4 +139,27 @@ export class TokenController { route: `${entityName}/${tokenAddress}/holders`, }); } + + @Get(":tokenAddress/overview") + @ApiParam({ + name: "tokenAddress", + type: String, + schema: { pattern: ADDRESS_REGEX_PATTERN }, + example: constants.tokenAddress, + description: "Valid hex token address", + }) + @ApiListPageOkResponse(BalanceForHolderDto, { description: "Successfully returned balance for holder list" }) + @ApiBadRequestResponse({ + description: "Token address is invalid or paging query params are not valid or out of range", + }) + @ApiNotFoundResponse({ description: "Token with the specified address does not exist" }) + public async getTokenOverview( + @Param("tokenAddress", new ParseAddressPipe()) tokenAddress: string + ): Promise { + if (!(await this.tokenService.exists(tokenAddress))) { + throw new NotFoundException(); + } + + return await this.balanceService.getSumAndCountBalances(tokenAddress); + } } diff --git a/packages/app/src/components/Contract.vue b/packages/app/src/components/Contract.vue index 3a9a484cfe..70e9a80db1 100644 --- a/packages/app/src/components/Contract.vue +++ b/packages/app/src/components/Contract.vue @@ -73,19 +73,11 @@ - + + diff --git a/packages/app/src/components/token/OverviewTokenInfoTable.vue b/packages/app/src/components/token/OverviewTokenInfoTable.vue new file mode 100644 index 0000000000..d507ed52a0 --- /dev/null +++ b/packages/app/src/components/token/OverviewTokenInfoTable.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/app/src/components/token/TokenHoldersList.vue b/packages/app/src/components/token/TokenHoldersList.vue index 1b15ec7bfd..ea6e6cfee4 100644 --- a/packages/app/src/components/token/TokenHoldersList.vue +++ b/packages/app/src/components/token/TokenHoldersList.vue @@ -8,10 +8,12 @@