From 78537ce9615c82e707795d38932b2c8ffda5d7ff Mon Sep 17 00:00:00 2001 From: Marko Arambasic Date: Mon, 27 Jan 2025 14:54:06 +0100 Subject: [PATCH 1/4] feat: add holders page --- .../api/src/balance/balance.service.spec.ts | 82 +++++++++ packages/api/src/balance/balance.service.ts | 40 +++++ .../api/src/balance/balanceForHolder.dto.ts | 13 ++ .../api/src/token/token.controller.spec.ts | 31 ++++ packages/api/src/token/token.controller.ts | 35 +++- packages/api/src/token/token.module.ts | 3 +- packages/app/src/components/Contract.vue | 26 +++ .../src/components/token/TokenHoldersList.vue | 155 ++++++++++++++++++ .../token/TokenHoldersListEmptyState.vue | 26 +++ packages/app/src/composables/common/Api.d.ts | 5 + .../app/src/composables/useTokenHolders.ts | 10 ++ packages/app/src/locales/en.json | 12 +- packages/app/src/locales/uk.json | 14 +- .../components/token/TokenHoldersList.spec.ts | 97 +++++++++++ .../tests/composables/useTokenHolders.spec.ts | 104 ++++++++++++ packages/app/tests/e2e/testId.json | 7 +- packages/app/tests/mocks.ts | 15 ++ 17 files changed, 670 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/balance/balanceForHolder.dto.ts create mode 100644 packages/app/src/components/token/TokenHoldersList.vue create mode 100644 packages/app/src/components/token/TokenHoldersListEmptyState.vue create mode 100644 packages/app/src/composables/useTokenHolders.ts create mode 100644 packages/app/tests/components/token/TokenHoldersList.spec.ts create mode 100644 packages/app/tests/composables/useTokenHolders.spec.ts diff --git a/packages/api/src/balance/balance.service.spec.ts b/packages/api/src/balance/balance.service.spec.ts index 6cbb72cee3..2a021d35b6 100644 --- a/packages/api/src/balance/balance.service.spec.ts +++ b/packages/api/src/balance/balance.service.spec.ts @@ -5,6 +5,8 @@ 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"; +jest.mock("../common/utils"); describe("BalanceService", () => { let service: BalanceService; @@ -299,4 +301,84 @@ 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>({ + getQuery: jest.fn().mockReturnValue(subQuerySql), + }); + mainQueryBuilderMock = mock>(); + (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 result = await service.getBalancesForTokenAddress(tokenAddress, pagingOptions); + expect(result).toEqual({ + items: [], + }); + }); + }); }); diff --git a/packages/api/src/balance/balance.service.ts b/packages/api/src/balance/balance.service.ts index e70626bd5b..753b2e0ab5 100644 --- a/packages/api/src/balance/balance.service.ts +++ b/packages/api/src/balance/balance.service.ts @@ -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; @@ -99,4 +102,41 @@ export class BalanceService { const balancesRecords = await balancesQuery.getMany(); return balancesRecords; } + + public async getBalancesForTokenAddress( + tokenAddress: string, + paginationOptions?: IPaginationOptions + ): 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.orderBy(`CAST(balances.balance AS NUMERIC)`, "DESC"); + + const balancesForToken = await paginate(balancesQuery, paginationOptions); + + return { + ...balancesForToken, + items: balancesForToken.items.map((item) => { + return { + balance: item.balance, + address: item.address, + }; + }), + }; + } } diff --git a/packages/api/src/balance/balanceForHolder.dto.ts b/packages/api/src/balance/balanceForHolder.dto.ts new file mode 100644 index 0000000000..0868123c91 --- /dev/null +++ b/packages/api/src/balance/balanceForHolder.dto.ts @@ -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; +} diff --git a/packages/api/src/token/token.controller.spec.ts b/packages/api/src/token/token.controller.spec.ts index 2b6cfe890f..f04eaa43e0 100644 --- a/packages/api/src/token/token.controller.spec.ts +++ b/packages/api/src/token/token.controller.spec.ts @@ -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"; @@ -16,11 +18,13 @@ describe("TokenController", () => { let controller: TokenController; let serviceMock: TokenService; let transferServiceMock: TransferService; + let balanceServiceMock: BalanceService; let token; beforeEach(async () => { serviceMock = mock(); transferServiceMock = mock(); + balanceServiceMock = mock(); token = { l2Address: "tokenAddress", @@ -37,6 +41,10 @@ describe("TokenController", () => { provide: TransferService, useValue: transferServiceMock, }, + { + provide: BalanceService, + useValue: balanceServiceMock, + }, ], }).compile(); @@ -144,4 +152,27 @@ describe("TokenController", () => { }); }); }); + describe("getTokenHolders", () => { + const tokenHolders = mock>(); + 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); + }); + }); + }); }); diff --git a/packages/api/src/token/token.controller.ts b/packages/api/src/token/token.controller.ts index 7232eed00c..cbd0463fd3 100644 --- a/packages/api/src/token/token.controller.ts +++ b/packages/api/src/token/token.controller.ts @@ -19,6 +19,8 @@ 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"; @@ -26,7 +28,11 @@ const entityName = "tokens"; @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" }) @@ -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> { + if (!(await this.tokenService.exists(tokenAddress))) { + throw new NotFoundException(); + } + + return await this.balanceService.getBalancesForTokenAddress(tokenAddress, { + ...pagingOptions, + route: `${entityName}/${tokenAddress}/holders`, + }); + } } diff --git a/packages/api/src/token/token.module.ts b/packages/api/src/token/token.module.ts index 43799ffab0..e80cba34c3 100644 --- a/packages/api/src/token/token.module.ts +++ b/packages/api/src/token/token.module.ts @@ -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 "src/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], diff --git a/packages/app/src/components/Contract.vue b/packages/app/src/components/Contract.vue index d60707f66b..2f15fa9353 100644 --- a/packages/app/src/components/Contract.vue +++ b/packages/app/src/components/Contract.vue @@ -73,11 +73,19 @@ + + + diff --git a/packages/app/src/components/token/TokenHoldersListEmptyState.vue b/packages/app/src/components/token/TokenHoldersListEmptyState.vue new file mode 100644 index 0000000000..2799f26550 --- /dev/null +++ b/packages/app/src/components/token/TokenHoldersListEmptyState.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/app/src/composables/common/Api.d.ts b/packages/app/src/composables/common/Api.d.ts index f328127b4f..832955a559 100644 --- a/packages/app/src/composables/common/Api.d.ts +++ b/packages/app/src/composables/common/Api.d.ts @@ -139,5 +139,10 @@ declare namespace Api { totalTransactions: number; isEvmLike: boolean; }; + + type TokenHolder = { + address: string; + balance: string; + }; } } diff --git a/packages/app/src/composables/useTokenHolders.ts b/packages/app/src/composables/useTokenHolders.ts new file mode 100644 index 0000000000..c7067aea6f --- /dev/null +++ b/packages/app/src/composables/useTokenHolders.ts @@ -0,0 +1,10 @@ +import useFetchCollection from "./common/useFetchCollection"; +import useContext from "./useContext"; + +export type TokenHolder = Api.Response.TokenHolder; + +export default (tokenAddress: string, context = useContext()) => { + return useFetchCollection( + new URL(`/tokens/${tokenAddress}/holders`, context.currentNetwork.value.apiUrl) + ); +}; diff --git a/packages/app/src/locales/en.json b/packages/app/src/locales/en.json index 70faef1b62..1261641a09 100644 --- a/packages/app/src/locales/en.json +++ b/packages/app/src/locales/en.json @@ -598,6 +598,15 @@ "tokenAddress": "Token Address" } }, + "tokenHolders": { + "notFound": "Token doesn't have any holders at this moment", + "table": { + "address": "Address", + "balance": "Quantity", + "percentage": "Percentage", + "value": "Value" + } + }, "pageError": { "title": "Something went wrong", "subtitle": "Unknown request, please try again, or go to the homepage.", @@ -693,7 +702,8 @@ "transactions": "Transactions", "contract": "Contract", "events": "Events", - "transfers": "Transfers" + "transfers": "Transfers", + "holders": "Holders" }, "contractInfoTabs": { "contract": "Contract", diff --git a/packages/app/src/locales/uk.json b/packages/app/src/locales/uk.json index ae5ed3089c..54ac440a78 100644 --- a/packages/app/src/locales/uk.json +++ b/packages/app/src/locales/uk.json @@ -356,6 +356,15 @@ "tokenAddress": "Адреса Токена" } }, + "tokenHolders": { + "notFound": "Для даного токена токен-власники відсутні на цей момент", + "table": { + "address": "Адреса", + "balance": "Баланс", + "percentage": "Відсоток", + "value": "Вартість" + } + }, "timeMessages": { "justNow": "щойно", "past": "тому", @@ -431,7 +440,10 @@ }, "tabs": { "transactions": "Транзакції", - "contract": "Контракт" + "contract": "Контракт", + "events": "Події", + "transfers": "Трансфери", + "holders": "Власники" }, "debuggerTool": { "title": "zkEVM Налагоджувач", diff --git a/packages/app/tests/components/token/TokenHoldersList.spec.ts b/packages/app/tests/components/token/TokenHoldersList.spec.ts new file mode 100644 index 0000000000..5131415268 --- /dev/null +++ b/packages/app/tests/components/token/TokenHoldersList.spec.ts @@ -0,0 +1,97 @@ +import { computed } from "vue"; +import { createI18n } from "vue-i18n"; + +import { afterAll, beforeAll, describe, expect, it, type SpyInstance, vi } from "vitest"; + +import { render, type RenderResult } from "@testing-library/vue"; +import { RouterLinkStub } from "@vue/test-utils"; + +import { useContextMock, useTokenHoldersMock } from "../../mocks"; + +import TokenHolderList from "@/components/token/TokenHoldersList.vue"; + +import enUS from "@/locales/en.json"; +import elements from "tests/e2e/testId.json"; + +import $testId from "@/plugins/testId"; + +const router = { + push: vi.fn(), +}; +const routeQueryMock = vi.fn(() => ({})); +vi.mock("vue-router", () => ({ + useRouter: () => router, + useRoute: () => ({ + query: routeQueryMock(), + }), +})); + +describe("TokenListTable:", () => { + const i18n = createI18n({ + locale: "en", + allowComposition: true, + messages: { + en: enUS, + }, + }); + + let renderResult: RenderResult | null; + let mockContext: SpyInstance; + let mockHolders: SpyInstance; + + beforeAll(() => { + mockHolders = useTokenHoldersMock({ + data: computed(() => [ + { + address: "0xe1134444211593Cfda9fc9eCc7B43208615556E2", + balance: "5000000000000000000", + }, + ]), + }); + mockContext = useContextMock(); + + renderResult = render(TokenHolderList, { + global: { + plugins: [i18n, $testId], + stubs: { RouterLink: RouterLinkStub }, + }, + props: { + tokenInfo: { + decimals: 18, + iconURL: "https://icon.com", + l1Address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + l2Address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + liquidity: 22000000000000000000000, + name: "Ether", + symbol: "ETH", + usdPrice: 150, + }, + loading: false, + useQueryPagination: false, + notFound: false, + }, + }); + }); + + afterAll(() => { + mockContext?.mockRestore(); + mockHolders?.mockRestore(); + renderResult?.unmount(); + }); + + it("renders transaction hash column", () => { + expect(renderResult!.getByTestId(elements.tokenHoldersAddress).textContent).toEqual("0xe11344442115...56E2"); + }); + + it("renders timestamp column", () => { + expect(renderResult!.getByTestId(elements.tokenHoldersBalance).textContent).toEqual("5.0"); + }); + + it("renders type column", () => { + expect(renderResult!.getByTestId(elements.tokenHoldersPercentage).textContent).toEqual("0.0227 %"); + }); + + it("renders from column", () => { + expect(renderResult!.getByTestId(elements.tokenHoldersValue).textContent).toEqual("$750.00"); + }); +}); diff --git a/packages/app/tests/composables/useTokenHolders.spec.ts b/packages/app/tests/composables/useTokenHolders.spec.ts new file mode 100644 index 0000000000..8a87ce935b --- /dev/null +++ b/packages/app/tests/composables/useTokenHolders.spec.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, type SpyInstance, vi } from "vitest"; + +import { $fetch } from "ohmyfetch"; + +import { useContextMock } from "../mocks"; + +import useTokenHolders from "@/composables/useTokenHolders"; + +vi.mock("ohmyfetch", () => { + return { + $fetch: vi.fn(() => + Promise.resolve({ + items: [ + { + address: "0xe1134444211593Cfda9fc9eCc7B43208615556E2", + balance: "5000000000000000000", + }, + ], + meta: { + totalItems: 1, + page: 1, + pageSize: 10, + totalPages: 1, + itemCount: 1, + }, + }) + ), + }; +}); +const tokenAddress = "0x0faF6df7054946141266420b43783387A78d82A9"; + +describe("useTokenHolders:", () => { + let mockContext: SpyInstance; + + beforeEach(() => { + mockContext = useContextMock(); + }); + + afterEach(() => { + mockContext?.mockRestore(); + }); + /* eslint-disable @typescript-eslint/no-explicit-any */ + it("creates useTokenHolders composable", () => { + const composable = useTokenHolders(tokenAddress); + expect(composable.pending).toBeDefined(); + expect(composable.failed).toBeDefined(); + expect(composable.load).toBeDefined(); + expect(composable.data).toBeDefined(); + }); + + it("gets token holders from API", async () => { + const composable = useTokenHolders(tokenAddress); + await composable.load(1); + const batch = (composable.data.value || [])[0]; + + expect(composable.data.value?.length).toBe(1); + expect(batch).toEqual({ + address: "0xe1134444211593Cfda9fc9eCc7B43208615556E2", + balance: "5000000000000000000", + }); + }); + + it("sets pending to true when request pending", async () => { + const composable = useTokenHolders(tokenAddress); + const promise = composable.load(1); + + expect(composable.pending.value).toEqual(true); + await promise; + }); + + it("sets pending to false when request completed", async () => { + const composable = useTokenHolders(tokenAddress); + await composable.load(1); + + expect(composable.pending.value).toEqual(false); + }); + + it("sets failed to false when request completed", async () => { + const composable = useTokenHolders(tokenAddress); + await composable.load(1); + + expect(composable.failed.value).toEqual(false); + }); + + it("sets failed to true when request failed", async () => { + const composable = useTokenHolders(tokenAddress); + const mock = ($fetch as any).mockRejectedValue(new Error()); + + await composable.load(1); + + expect(composable.failed.value).toEqual(true); + mock.mockRestore(); + }); + + it("sets batches to null when request failed", async () => { + const composable = useTokenHolders(tokenAddress); + const mock = ($fetch as any).mockRejectedValue(new Error()); + + await composable.load(1); + + expect(composable.data.value).toEqual(null); + mock.mockRestore(); + }); +}); diff --git a/packages/app/tests/e2e/testId.json b/packages/app/tests/e2e/testId.json index 23625394d9..849ac7254a 100644 --- a/packages/app/tests/e2e/testId.json +++ b/packages/app/tests/e2e/testId.json @@ -27,10 +27,15 @@ "latestTransactionsTable": "latest-transaction-table", "latestBatchesTable": "latest-batches-table", "tokensTable": "tokens-table", + "tokensHoldersTable": "tokens-holders-table", "toAddress": "to-address", "transferType": "transfer-type", "transferFromOrigin": "transfer-from-origin", "transferToOrigin": "transfer-to-origin", "transferFromOriginTablet": "transfer-from-origin-tablet", - "transferToOriginTablet": "transfer-to-origin-tablet" + "transferToOriginTablet": "transfer-to-origin-tablet", + "tokenHoldersAddress": "token-holders-address", + "tokenHoldersBalance": "token-holders-balance", + "tokenHoldersPercentage": "token-holders-percentage", + "tokenHoldersValue": "token-holders-value" } diff --git a/packages/app/tests/mocks.ts b/packages/app/tests/mocks.ts index 38ac0897e8..5e556ce616 100644 --- a/packages/app/tests/mocks.ts +++ b/packages/app/tests/mocks.ts @@ -10,6 +10,7 @@ import * as useContext from "@/composables/useContext"; import * as useContractEvents from "@/composables/useContractEvents"; import * as useContractInteractionFactory from "@/composables/useContractInteraction"; import * as useTokenFactory from "@/composables/useToken"; +import * as useTokenHolders from "@/composables/useTokenHolders"; import * as useTokenLibraryMockFactory from "@/composables/useTokenLibrary"; import * as useTransaction from "@/composables/useTransaction"; import * as useTransactions from "@/composables/useTransactions"; @@ -194,3 +195,17 @@ export const useContextMock = (params: any = {}) => { return mockContextConfig; }; + +export const useTokenHoldersMock = (params: any = {}) => { + const mockBlocks = vi.spyOn(useTokenHolders, "default").mockReturnValue({ + data: ref([]), + total: ref(0), + load: () => vi.fn(), + pending: ref(false), + failed: ref(false), + page: ref(1), + pageSize: ref(10), + ...params, + }); + return mockBlocks; +}; From 51ff61702d7dcb9b2ec6d530f699e5b8f0a17148 Mon Sep 17 00:00:00 2001 From: Marko Arambasic Date: Mon, 27 Jan 2025 15:16:05 +0100 Subject: [PATCH 2/4] fix: proper import and add check for pending token info --- packages/api/src/token/token.module.ts | 2 +- packages/app/src/components/Contract.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/token/token.module.ts b/packages/api/src/token/token.module.ts index e80cba34c3..f15e8f8826 100644 --- a/packages/api/src/token/token.module.ts +++ b/packages/api/src/token/token.module.ts @@ -6,7 +6,7 @@ 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 "src/balance/balance.module"; +import { BalanceModule } from "../balance/balance.module"; @Module({ imports: [TypeOrmModule.forFeature([Token, Block, Transaction]), TransferModule, BalanceModule], controllers: [TokenController], diff --git a/packages/app/src/components/Contract.vue b/packages/app/src/components/Contract.vue index 2f15fa9353..6f8f0df509 100644 --- a/packages/app/src/components/Contract.vue +++ b/packages/app/src/components/Contract.vue @@ -73,7 +73,7 @@ -