Skip to content

Commit

Permalink
fix: create proper token page with new api and add holders page
Browse files Browse the repository at this point in the history
  • Loading branch information
kiriyaga-txfusion committed Jan 29, 2025
1 parent 47d2e0b commit aa3d25c
Show file tree
Hide file tree
Showing 15 changed files with 570 additions and 88 deletions.
13 changes: 13 additions & 0 deletions packages/api/src/api/dtos/token/tokenOverview.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
116 changes: 116 additions & 0 deletions packages/api/src/balance/balance.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<SelectQueryBuilder<Balance>>({
getQuery: jest.fn().mockReturnValue(subQuerySql),
});
mainQueryBuilderMock = mock<SelectQueryBuilder<Balance>>();
(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,
});
});
});
});
33 changes: 32 additions & 1 deletion packages/api/src/balance/balance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -139,4 +140,34 @@ export class BalanceService {
}),
};
}

public async getSumAndCountBalances(tokenAddress: string): Promise<TokenOverviewDto> {
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,
};
}
}
43 changes: 42 additions & 1 deletion packages/api/src/token/token.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -191,4 +192,44 @@ describe("TokenController", () => {
});
});
});

describe("getTokenOverview", () => {
describe("when token exists", () => {
const tokenOverview = mock<TokenOverviewDto>({
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);
}
});
});
});
});
26 changes: 25 additions & 1 deletion packages/api/src/token/token.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<TokenOverviewDto> {
if (!(await this.tokenService.exists(tokenAddress))) {
throw new NotFoundException();
}

return await this.balanceService.getSumAndCountBalances(tokenAddress);
}
}
25 changes: 0 additions & 25 deletions packages/app/src/components/Contract.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,11 @@
<template #tab-4-content>
<ContractEvents :contract="contract" />
</template>
<template v-if="tokenInfo && !isLoadingTokenInfo" #tab-5-content>
<TokenHoldersList v-if="tokenInfo && !isLoadingTokenInfo" :tokenInfo="tokenInfo">
<template #not-found>
<TokenHoldersListEmptyState />
</template>
</TokenHoldersList>
</template>
</Tabs>
</div>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { watch } from "vue";
import { useI18n } from "vue-i18n";
import { CheckCircleIcon } from "@heroicons/vue/solid";
Expand All @@ -101,13 +93,9 @@ import ContractInfoTab from "@/components/contract/ContractInfoTab.vue";
import ContractInfoTable from "@/components/contract/InfoTable.vue";
import TransactionEmptyState from "@/components/contract/TransactionEmptyState.vue";
import ContractEvents from "@/components/event/ContractEvents.vue";
import TokenHoldersList from "@/components/token/TokenHoldersList.vue";
import TokenHoldersListEmptyState from "@/components/token/TokenHoldersListEmptyState.vue";
import TransactionsTable from "@/components/transactions/Table.vue";
import TransfersTable from "@/components/transfers/Table.vue";
import useToken from "@/composables/useToken";
import type { BreadcrumbItem } from "@/components/common/Breadcrumbs.vue";
import type { Contract } from "@/composables/useAddress";
Expand Down Expand Up @@ -140,7 +128,6 @@ const tabs = computed(() => [
icon: props.contract?.verificationInfo ? CheckCircleIcon : null,
},
{ title: t("tabs.events"), hash: "#events" },
...(tokenInfo?.value?.l2Address ? [{ title: t("tabs.holders"), hash: "#holders" }] : []),
]);
const breadcrumbItems = computed((): BreadcrumbItem[] | [] => {
Expand All @@ -155,18 +142,6 @@ const breadcrumbItems = computed((): BreadcrumbItem[] | [] => {
return [];
});
const { getTokenInfo, tokenInfo, isRequestPending: isLoadingTokenInfo } = useToken();
getTokenInfo(props.contract?.address);
watch(
() => props.contract,
(newContract) => {
if (newContract) {
getTokenInfo(newContract.address);
}
}
);
const contractName = computed(() => props.contract?.verificationInfo?.request.contractName.replace(/.*\.sol:/, ""));
const contractABI = computed(() => props.contract?.verificationInfo?.artifacts.abi);
Expand Down
Loading

0 comments on commit aa3d25c

Please sign in to comment.