Skip to content

Commit

Permalink
Merge pull request #379 from matter-labs/kiriyaga-txfusion-token-hold…
Browse files Browse the repository at this point in the history
…er-page

feat: add holders page
  • Loading branch information
kiriyaga-txfusion authored Jan 28, 2025
2 parents 7359d6f + 0140f0d commit 47d2e0b
Show file tree
Hide file tree
Showing 17 changed files with 732 additions and 5 deletions.
128 changes: 128 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 @@ -299,4 +302,129 @@ 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);
});

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: [] });
});
});
});
40 changes: 40 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,9 @@ 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 { paginate } from "../common/utils";
import { IPaginationOptions, Pagination } from "nestjs-typeorm-paginate";

export interface TokenBalance {
balance: string;
Expand Down Expand Up @@ -99,4 +102,41 @@ 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,
};
}),
};
}
}
13 changes: 13 additions & 0 deletions packages/api/src/balance/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;
}
47 changes: 47 additions & 0 deletions packages/api/src/token/token.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 { BalanceService } from "../balance/balance.service";

describe("TokenController", () => {
const tokenAddress = "tokenAddress";
Expand All @@ -16,11 +18,13 @@ describe("TokenController", () => {
let controller: TokenController;
let serviceMock: TokenService;
let transferServiceMock: TransferService;
let balanceServiceMock: BalanceService;
let token;

beforeEach(async () => {
serviceMock = mock<TokenService>();
transferServiceMock = mock<TransferService>();
balanceServiceMock = mock<BalanceService>();

token = {
l2Address: "tokenAddress",
Expand All @@ -37,6 +41,10 @@ describe("TokenController", () => {
provide: TransferService,
useValue: transferServiceMock,
},
{
provide: BalanceService,
useValue: balanceServiceMock,
},
],
}).compile();

Expand Down Expand Up @@ -144,4 +152,43 @@ describe("TokenController", () => {
});
});
});
describe("getTokenHolders", () => {
const tokenHolders = mock<Pagination<BalanceForHolderDto>>();
describe("when token exists", () => {
beforeEach(() => {
(serviceMock.exists as jest.Mock).mockResolvedValueOnce(true);
(balanceServiceMock.getBalancesForTokenAddress as jest.Mock).mockResolvedValueOnce(tokenHolders);
});

it("queries transfers with the specified options", async () => {
await controller.getTokenHolders(tokenAddress, pagingOptionsWithLimit);
expect(balanceServiceMock.getBalancesForTokenAddress).toHaveBeenCalledTimes(1);
expect(balanceServiceMock.getBalancesForTokenAddress).toHaveBeenCalledWith(tokenAddress, {
...pagingOptionsWithLimit,
route: `tokens/${tokenAddress}/holders`,
});
});

it("returns token transfers", async () => {
const result = await controller.getTokenHolders(tokenAddress, pagingOptionsWithLimit);
expect(result).toBe(tokenHolders);
});
});

describe("when token does not exist", () => {
beforeEach(() => {
(serviceMock.exists as jest.Mock).mockResolvedValueOnce(false);
});

it("throws NotFoundException", async () => {
expect.assertions(1);

try {
await controller.getTokenHolders(tokenAddress, pagingOptionsWithLimit);
} catch (error) {
expect(error).toBeInstanceOf(NotFoundException);
}
});
});
});
});
35 changes: 34 additions & 1 deletion packages/api/src/token/token.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ import { ParseLimitedIntPipe } from "../common/pipes/parseLimitedInt.pipe";
import { ParseAddressPipe, ADDRESS_REGEX_PATTERN } from "../common/pipes/parseAddress.pipe";
import { swagger } from "../config/featureFlags";
import { constants } from "../config/docs";
import { BalanceService } from "../balance/balance.service";
import { BalanceForHolderDto } from "../balance/balanceForHolder.dto";

const entityName = "tokens";

@ApiTags("Token BFF")
@ApiExcludeController(!swagger.bffEnabled)
@Controller(entityName)
export class TokenController {
constructor(private readonly tokenService: TokenService, private readonly transferService: TransferService) {}
constructor(
private readonly tokenService: TokenService,
private readonly transferService: TransferService,
private readonly balanceService: BalanceService
) {}

@Get("")
@ApiListPageOkResponse(TokenDto, { description: "Successfully returned token list" })
Expand Down Expand Up @@ -105,4 +111,31 @@ export class TokenController {
}
);
}

@Get(":tokenAddress/holders")
@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 getTokenHolders(
@Param("tokenAddress", new ParseAddressPipe()) tokenAddress: string,
@Query() pagingOptions: PagingOptionsWithMaxItemsLimitDto
): Promise<Pagination<BalanceForHolderDto>> {
if (!(await this.tokenService.exists(tokenAddress))) {
throw new NotFoundException();
}

return await this.balanceService.getBalancesForTokenAddress(tokenAddress, {
...pagingOptions,
route: `${entityName}/${tokenAddress}/holders`,
});
}
}
3 changes: 2 additions & 1 deletion packages/api/src/token/token.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { Token } from "./token.entity";
import { Block } from "../block/block.entity";
import { Transaction } from "../transaction/entities/transaction.entity";
import { TransferModule } from "../transfer/transfer.module";
import { BalanceModule } from "../balance/balance.module";
@Module({
imports: [TypeOrmModule.forFeature([Token, Block, Transaction]), TransferModule],
imports: [TypeOrmModule.forFeature([Token, Block, Transaction]), TransferModule, BalanceModule],
controllers: [TokenController],
providers: [TokenService],
exports: [TokenService],
Expand Down
Loading

0 comments on commit 47d2e0b

Please sign in to comment.