Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add token page with holders list #373

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/api/src/api/dtos/balances/balanceForHolder.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from "@nestjs/swagger";

export class BalanceForHolderDto {
@ApiProperty({ type: String, description: "Token balance", example: "0xd754F" })
public readonly balance: string;

@ApiProperty({
type: String,
description: "Holder address",
example: "0x868e3b4391ff95C1cd99C6F9B5332b4EC2b8A63A",
})
public readonly address: string;
}
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;
}
244 changes: 244 additions & 0 deletions packages/api/src/balance/balance.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Repository, SelectQueryBuilder } from "typeorm";
import { BalanceService } from "./balance.service";
import { Balance } from "./balance.entity";
import { hexTransformer } from "../common/transformers/hex.transformer";
import * as utils from "../common/utils";
import { IPaginationMeta, Pagination } from "nestjs-typeorm-paginate";
jest.mock("../common/utils");

describe("BalanceService", () => {
let service: BalanceService;
Expand Down Expand Up @@ -228,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 @@ -299,4 +306,241 @@ describe("BalanceService", () => {
expect(result).toEqual([]);
});
});

describe("getBalancesForTokenAddress", () => {
const subQuerySql = "subQuerySql";
const tokenAddress = "0x91d0a23f34e535e44df8ba84c53a0945cf0eeb69";
let subQueryBuilderMock;
let mainQueryBuilderMock;
const pagingOptions = {
limit: 10,
page: 2,
};
beforeEach(() => {
subQueryBuilderMock = mock<SelectQueryBuilder<Balance>>({
getQuery: jest.fn().mockReturnValue(subQuerySql),
});
mainQueryBuilderMock = mock<SelectQueryBuilder<Balance>>();
(utils.paginate as jest.Mock).mockResolvedValue({
items: [],
});
(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.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("latest_balances");
});

it("selects required fields in the sub query", async () => {
await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
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.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(subQueryBuilderMock.where).toHaveBeenCalledTimes(1);
expect(subQueryBuilderMock.where).toHaveBeenCalledWith(`"tokenAddress" = :tokenAddress`);
});

it("groups by address and tokenAddress in the sub query", async () => {
await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
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.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(repositoryMock.createQueryBuilder).toHaveBeenCalledWith("balances");
});

it("joins main query with the sub query", async () => {
await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
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 and addresses params", async () => {
await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledTimes(1);
expect(mainQueryBuilderMock.setParameter).toHaveBeenCalledWith("tokenAddress", hexTransformer.to(tokenAddress));
});

it("returns pagination results", async () => {
const balances = [
mock<Balance>({ balance: "2222", address: "0x111111" }),
mock<Balance>({ balance: "3333", address: "0x222222" }),
];
const paginationResult = mock<Pagination<Balance, IPaginationMeta>>({
meta: {
totalItems: 2,
itemCount: 2,
itemsPerPage: 10,
totalPages: 1,
currentPage: 1,
},
links: {
first: "first",
previous: "previous",
next: "next",
last: "last",
},
items: [balances[0], balances[1]],
});
(utils.paginate as jest.Mock).mockResolvedValue(paginationResult);
const result = await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(utils.paginate).toHaveBeenCalledTimes(1);
expect(utils.paginate).toHaveBeenCalledWith(mainQueryBuilderMock, pagingOptions);
expect(result).toStrictEqual({
...paginationResult,
items: [
{ balance: balances[0].balance, address: balances[0].address },
{ balance: balances[1].balance, address: balances[1].address },
],
});
});

it("returns empty pagination results", async () => {
const paginationResult = mock<Pagination<Balance, IPaginationMeta>>({
meta: {
totalItems: 0,
itemCount: 0,
itemsPerPage: 10,
totalPages: 0,
currentPage: 1,
},
items: [],
});
(utils.paginate as jest.Mock).mockResolvedValue(paginationResult);
const result = await service.getBalancesForTokenAddress(tokenAddress, pagingOptions);
expect(utils.paginate).toHaveBeenCalledTimes(1);
expect(utils.paginate).toHaveBeenCalledWith(mainQueryBuilderMock, pagingOptions);
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.select).toHaveBeenCalledTimes(1);
expect(mainQueryBuilderMock.addSelect).toHaveBeenCalledTimes(1);
expect(mainQueryBuilderMock.select).toHaveBeenCalledWith(
"SUM(CAST(balances.balance AS NUMERIC))",
"totalBalance"
);
expect(mainQueryBuilderMock.addSelect).toHaveBeenCalledWith("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,
});
});
});
});
71 changes: 71 additions & 0 deletions packages/api/src/balance/balance.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +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 "../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 @@ -99,4 +103,71 @@ export class BalanceService {
const balancesRecords = await balancesQuery.getMany();
return balancesRecords;
}

public async getBalancesForTokenAddress(
tokenAddress: string,
paginationOptions?: IPaginationOptions
): Promise<Pagination<BalanceForHolderDto>> {
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.orderBy(`CAST(balances.balance AS NUMERIC)`, "DESC");

const balancesForToken = await paginate<Balance>(balancesQuery, paginationOptions);

return {
...balancesForToken,
items: balancesForToken.items.map((item) => {
return {
balance: item.balance,
address: item.address,
};
}),
};
}

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,
};
}
}
Loading
Loading