From 308d3208f438cb91d7a2e9b2f0bb0c91a5189f32 Mon Sep 17 00:00:00 2001 From: Beast Date: Thu, 1 Oct 2026 19:19:40 +0800 Subject: [PATCH 1/2] feat: add filter account capabiliity --- scripts/graphql-bench.ts | 100 ++++-- src/api/accounts.tsx | 248 +++++++++------ src/api/unified-transactions.tsx | 69 ++++- .../network-provider/network-provider.tsx | 4 +- .../common/table-columns/ACCOUNT_COLUMNS.tsx | 4 +- .../AccountAllTransactions.tsx | 9 +- .../account-all-transactions/hook.tsx | 57 ++-- .../AccountInformation.tsx | 5 +- .../accounts-table/AccountsTable.tsx | 27 +- .../account-listing/accounts-table/hook.tsx | 71 ++++- .../multisig-transactions/hook.tsx | 13 +- .../transactions-table/TransactionsTable.tsx | 27 +- .../transactions-table/hook.tsx | 61 ++-- .../account-type-filter/AccountTypeFilter.tsx | 286 ++++++++++++++++++ .../ui/composites/data-table/TableControl.tsx | 3 +- src/constants/account-types.ts | 51 ++++ .../useAccountTypeFilteredTransactions.ts | 103 +++++++ src/hooks/useAccountTypeFilters.ts | 88 ++++++ .../account-type-filter-registry.ts | 221 ++++++++++++++ src/lib/graphql-benchmark/bootstrap.ts | 61 +++- src/lib/graphql-benchmark/explain.test.ts | 163 ++++++++++ src/lib/graphql-benchmark/explain.ts | 223 ++++++++++++++ src/lib/graphql-benchmark/index.ts | 14 +- src/lib/graphql-benchmark/registry.ts | 30 +- src/lib/graphql-benchmark/run.ts | 55 ++-- src/lib/graphql-benchmark/types.ts | 2 + src/schemas/account.ts | 14 +- src/schemas/unified-list-transaction.ts | 5 + src/types/react-table.d.ts | 5 + src/utils/account-type-filter.test.ts | 222 ++++++++++++++ src/utils/account-type-filter.ts | 150 +++++++++ src/utils/classify-account-address.test.ts | 59 +++- src/utils/classify-account-address.ts | 10 +- src/utils/uncounted-pagination.test.ts | 49 +++ src/utils/uncounted-pagination.ts | 33 ++ src/utils/unified-transaction-filters.test.ts | 41 +-- src/utils/unified-transaction-filters.ts | 31 +- 37 files changed, 2308 insertions(+), 306 deletions(-) create mode 100644 src/components/ui/composites/account-type-filter/AccountTypeFilter.tsx create mode 100644 src/constants/account-types.ts create mode 100644 src/hooks/useAccountTypeFilteredTransactions.ts create mode 100644 src/hooks/useAccountTypeFilters.ts create mode 100644 src/lib/graphql-benchmark/account-type-filter-registry.ts create mode 100644 src/lib/graphql-benchmark/explain.test.ts create mode 100644 src/lib/graphql-benchmark/explain.ts create mode 100644 src/utils/account-type-filter.test.ts create mode 100644 src/utils/account-type-filter.ts create mode 100644 src/utils/uncounted-pagination.test.ts create mode 100644 src/utils/uncounted-pagination.ts diff --git a/scripts/graphql-bench.ts b/scripts/graphql-bench.ts index 14437c7..78372ed 100644 --- a/scripts/graphql-bench.ts +++ b/scripts/graphql-bench.ts @@ -1,3 +1,10 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { + explainGraphqlBenchmarks, + formatExplainReport +} from '../src/lib/graphql-benchmark/explain'; import { runGraphqlBenchmarks } from '../src/lib/graphql-benchmark/run'; import { graphqlBenchmarkRunFailed } from '../src/lib/graphql-benchmark/suite-failure'; import type { GraphqlBenchmarkSuite } from '../src/lib/graphql-benchmark/types'; @@ -26,33 +33,43 @@ const defaultUrl = : 'https://sub2.quantus.com/v1/graphql'; const endpoint = process.env.GRAPHQL_BENCH_URL ?? defaultUrl; const samples = Number(argValue('--samples') ?? (suite === 'mobile' ? 5 : 1)); +const group = argValue('--group'); +const explain = args.includes('--explain'); +const outPath = argValue('--out'); -async function main() { - // eslint-disable-next-line no-console - console.error(`GraphQL bench [${suite}] samples=${samples} → ${endpoint}\n`); - const { results, bootstrapContext, bootstrapRequestFailures } = - await runGraphqlBenchmarks({ - endpoint, - suite, - samples - }); +function printBootstrap(context: unknown, failures: string[]) { // eslint-disable-next-line no-console - console.log('Bootstrap context:', JSON.stringify(bootstrapContext, null, 2)); - if (bootstrapRequestFailures.length > 0) { + console.log('Bootstrap context:', JSON.stringify(context, null, 2)); + if (failures.length > 0) { // eslint-disable-next-line no-console console.log('\nBootstrap request failures:'); - for (const failure of bootstrapRequestFailures) { + for (const failure of failures) { // eslint-disable-next-line no-console console.log(` ${failure}`); } } +} + +async function bench() { + // eslint-disable-next-line no-console + console.error( + `GraphQL bench [${suite}${group ? ` / ${group}` : ''}] samples=${samples} → ${endpoint}\n` + ); + const { results, bootstrapContext, bootstrapRequestFailures } = + await runGraphqlBenchmarks({ + endpoint, + suite, + group, + samples + }); + printBootstrap(bootstrapContext, bootstrapRequestFailures); // eslint-disable-next-line no-console console.log('\nResults (slowest first):'); for (const r of results) { - const group = r.group ? `[${r.group}] ` : ''; + const label = r.group ? `[${r.group}] ` : ''; if (r.skipped) { // eslint-disable-next-line no-console - console.log(` ${group}${r.name} SKIPPED ${r.skipReason ?? ''}`); + console.log(` ${label}${r.name} SKIPPED ${r.skipReason ?? ''}`); } else { const spread = r.minMs != null && r.maxMs != null @@ -61,13 +78,62 @@ async function main() { const rows = r.rowCount != null ? ` rows=${r.rowCount}` : ''; // eslint-disable-next-line no-console console.log( - ` ${group}${r.name} ${r.durationMs}ms${spread} bytes=${r.responseBytes ?? '—'}${rows} ${r.errorMessage ?? 'OK'}` + ` ${label}${r.name} ${r.durationMs}ms${spread} bytes=${r.responseBytes ?? '—'}${rows} ${r.errorMessage ?? 'OK'}` ); } } - process.exit( - graphqlBenchmarkRunFailed(results, bootstrapRequestFailures) ? 1 : 0 + return graphqlBenchmarkRunFailed(results, bootstrapRequestFailures); +} + +async function explainPlans() { + const adminSecret = process.env.HASURA_ADMIN_SECRET; + if (!adminSecret) { + throw new Error('--explain needs HASURA_ADMIN_SECRET in the environment'); + } + // eslint-disable-next-line no-console + console.error( + `GraphQL explain [${suite}${group ? ` / ${group}` : ''}] → ${endpoint}/explain\n` + ); + const generatedAt = new Date(); + const { results, bootstrapContext, bootstrapRequestFailures } = + await explainGraphqlBenchmarks({ endpoint, adminSecret, suite, group }); + + const file = resolve( + outPath ?? + `out/graphql-explain/${suite}-${group ?? 'all'}-${generatedAt + .toISOString() + .replace(/[:.]/g, '-')}.md` + ); + await mkdir(dirname(file), { recursive: true }); + await writeFile( + file, + formatExplainReport({ + endpoint, + suite, + group, + generatedAt, + bootstrapContext, + bootstrapRequestFailures, + results + }) ); + + for (const r of results) { + const label = r.group ? `[${r.group}] ` : ''; + let status = 'OK'; + if (r.skipped) status = `SKIPPED ${r.skipReason ?? ''}`; + else if (r.errorMessage) status = `ERROR ${r.errorMessage}`; + // eslint-disable-next-line no-console + console.log(` ${label}${r.name} ${status}`); + } + // eslint-disable-next-line no-console + console.log(`\nExplain report written to ${file}`); + return graphqlBenchmarkRunFailed(results, bootstrapRequestFailures); +} + +async function main() { + const failed = explain ? await explainPlans() : await bench(); + process.exit(failed ? 1 : 0); } main().catch((e) => { diff --git a/src/api/accounts.tsx b/src/api/accounts.tsx index 0a3a35c..6b48e19 100644 --- a/src/api/accounts.tsx +++ b/src/api/accounts.tsx @@ -1,15 +1,136 @@ import type { QueryHookOptions } from '@apollo/client'; import { gql, useQuery } from '@apollo/client'; +import type { Account_Bool_Exp } from '@/__generated__/graphql'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; import type { AccountSorts } from '@/constants/query-sorts'; import type { + AccountListItem, + AccountListPageResponse, AccountListResponse, AccountResponse, AccountStatsResponse } from '@/schemas'; import type { PaginatedQueryVariables } from '@/types/query'; import { getAccountStatsUtcDateRange } from '@/utils/get-account-stats-utc-date-range'; +import { peekedLimit, takePeekedPage } from '@/utils/uncounted-pagination'; + +const ACCOUNT_LIST_FIELDS = ` + id + free + frozen + reserved + is_deposit_only + is_high_security + is_guardian + is_multisig + minedBlocks(limit: 1) { + height + } +`; + +export const GET_ACCOUNTS = gql` + query GetAccounts( + $limit: Int + $offset: Int + $orderBy: [account_order_by!] + ) { + accounts: account(limit: $limit, offset: $offset, order_by: $orderBy) { + ${ACCOUNT_LIST_FIELDS} + } + meta: chain_stats_by_pk(id: "global") { + totalCount: total_accounts + } + } +`; + +/** Filtered list: no total, the caller peeks one extra row instead. */ +export const GET_FILTERED_ACCOUNTS = gql` + query GetFilteredAccounts( + $limit: Int + $offset: Int + $orderBy: [account_order_by!] + $where: account_bool_exp! + ) { + accounts: account( + limit: $limit + offset: $offset + order_by: $orderBy + where: $where + ) { + ${ACCOUNT_LIST_FIELDS} + } + } +`; + +export const GET_ACCOUNT_BY_ID = gql` + query GetAccountById($id: String!) { + account: account_by_pk(id: $id) { + id + free + frozen + reserved + is_deposit_only + minedBlocks(limit: 1) { + height + } + } + accountStats: account_stats_by_pk(id: $id) { + total_cancelled_transfers + total_executed_transfers + total_immediate_transfers + total_mined_blocks + total_rewards + total_scheduled_transfers + } + multisig: multisig_by_pk(id: $id) { + id + } + guardian: high_security_set_aggregate( + where: { who: { id: { _eq: $id } } } + ) { + aggregate { + totalCount: count + } + } + beneficiaries: high_security_set_aggregate( + where: { guardian: { id: { _eq: $id } } } + ) { + aggregate { + totalCount: count + } + } + } +`; + +// daily_active_account is one row per (UTC day, account) with sent/received flags, +// so the 7-day distinct counts read a few thousand indexed rows instead of the transfer table. +export const GET_ACCOUNTS_STATS = gql` + query GetAccountsStats($startDate: timestamptz!, $endDate: timestamptz!) { + all: chain_stats_by_pk(id: "global") { + total_accounts + } + + recentlyActive: daily_active_account_aggregate( + where: { date: { _gte: $startDate, _lte: $endDate }, sent: { _eq: true } } + ) { + aggregate { + count(columns: account_id, distinct: true) + } + } + + recentlyDeposited: daily_active_account_aggregate( + where: { + date: { _gte: $startDate, _lte: $endDate } + received: { _eq: true } + } + ) { + aggregate { + count(columns: account_id, distinct: true) + } + } + } +`; export const accounts = { useGetAll: ( @@ -18,28 +139,6 @@ export const accounts = { PaginatedQueryVariables > ) => { - const GET_ACCOUNTS = gql` - query GetAccounts( - $limit: Int - $offset: Int - $orderBy: [account_order_by!] - ) { - accounts: account(limit: $limit, offset: $offset, order_by: $orderBy) { - id - free - frozen - reserved - is_deposit_only - is_high_security - is_guardian - is_multisig - } - meta: chain_stats_by_pk(id: "global") { - totalCount: total_accounts - } - } - `; - return useQuery>( GET_ACCOUNTS, { @@ -52,47 +151,46 @@ export const accounts = { } ); }, - getById: () => { - const GET_ACCOUNT = gql` - query GetAccountById($id: String!) { - account: account_by_pk(id: $id) { - id - free - frozen - reserved - is_deposit_only - } - accountStats: account_stats_by_pk(id: $id) { - total_cancelled_transfers - total_executed_transfers - total_immediate_transfers - total_mined_blocks - total_rewards - total_scheduled_transfers - } - multisig: multisig_by_pk(id: $id) { - id - } - guardian: high_security_set_aggregate( - where: { who: { id: { _eq: $id } } } - ) { - aggregate { - totalCount: count - } - } - beneficiaries: high_security_set_aggregate( - where: { guardian: { id: { _eq: $id } } } - ) { - aggregate { - totalCount: count - } - } + useGetFiltered: ( + config: Omit< + QueryHookOptions< + { accounts: AccountListItem[] }, + PaginatedQueryVariables + >, + 'variables' + > & { + variables: PaginatedQueryVariables & { + where: Account_Bool_Exp; + }; + } + ) => { + const limit = config.variables.limit ?? QUERY_DEFAULT_LIMIT; + + const result = useQuery< + { accounts: AccountListItem[] }, + PaginatedQueryVariables + >(GET_FILTERED_ACCOUNTS, { + ...config, + variables: { + orderBy: config.variables.orderBy ?? { id: 'desc' }, + limit: peekedLimit(limit), + offset: config.variables.offset ?? 0, + where: config.variables.where } - `; + }); + + let data: AccountListPageResponse | undefined; + if (result.data) { + const page = takePeekedPage(result.data.accounts, limit); + data = { accounts: page.rows, hasNextPage: page.hasNextPage }; + } + return { ...result, data }; + }, + getById: () => { return { useQuery: (id: string, config?: QueryHookOptions) => - useQuery(GET_ACCOUNT, { + useQuery(GET_ACCOUNT_BY_ID, { ...config, variables: { id @@ -105,38 +203,6 @@ export const accounts = { ) => { const { startDate, endDate } = getAccountStatsUtcDateRange(); - // daily_active_account is one row per (UTC day, account) with sent/received flags, - // so the 7-day distinct counts read a few thousand indexed rows instead of the transfer table. - const GET_ACCOUNTS_STATS = gql` - query GetAccountsStats($startDate: timestamptz!, $endDate: timestamptz!) { - all: chain_stats_by_pk(id: "global") { - total_accounts - } - - recentlyActive: daily_active_account_aggregate( - where: { - date: { _gte: $startDate, _lte: $endDate } - sent: { _eq: true } - } - ) { - aggregate { - count(columns: account_id, distinct: true) - } - } - - recentlyDeposited: daily_active_account_aggregate( - where: { - date: { _gte: $startDate, _lte: $endDate } - received: { _eq: true } - } - ) { - aggregate { - count(columns: account_id, distinct: true) - } - } - } - `; - return useQuery(GET_ACCOUNTS_STATS, { ...config, variables: { diff --git a/src/api/unified-transactions.tsx b/src/api/unified-transactions.tsx index 43fd315..37252c6 100644 --- a/src/api/unified-transactions.tsx +++ b/src/api/unified-transactions.tsx @@ -8,6 +8,7 @@ import type { UnifiedListTransactionSorts } from '@/constants/query-sorts'; import type { RecentUnifiedListTransactionsResponse, UnifiedListTransactionListResponse, + UnifiedListTransactionPageResponse, UnifiedListTransactionsStatsResponse } from '@/schemas'; import type { PaginatedQueryVariables } from '@/types/query'; @@ -16,6 +17,7 @@ import { type ChainTransferTotals, sumChainTransferTotals } from '@/utils/sum-chain-transfer-totals'; +import { peekedLimit, takePeekedPage } from '@/utils/uncounted-pagination'; import { EXCLUDE_REWARD_TRANSFERS, extractAccountPartyId, @@ -77,7 +79,7 @@ const GET_UNIFIED_TRANSACTIONS = gql` `; /** Unfiltered list: total from O(1) chain_stats sum instead of full-table aggregate. */ -const GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL = gql` +export const GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL = gql` query GetUnifiedTransactionsWithChainTotal( $limit: Int $offset: Int @@ -99,7 +101,7 @@ const GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL = gql` `; /** Account party list: total from O(1) account_stats sum instead of filtered aggregate. */ -const GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL = gql` +export const GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL = gql` query GetUnifiedTransactionsWithAccountTotal( $limit: Int $offset: Int @@ -121,6 +123,25 @@ const GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL = gql` } `; +/** Account-type filtered list: no total, the caller peeks one extra row instead. */ +export const GET_FILTERED_UNIFIED_TRANSACTIONS = gql` + query GetFilteredUnifiedTransactions( + $limit: Int + $offset: Int + $orderBy: [unified_transaction_order_by!] + $where: unified_transaction_bool_exp! + ) { + transactions: unified_transaction( + limit: $limit + offset: $offset + order_by: $orderBy + where: $where + ) { + ${UNIFIED_TX_FIELDS} + } + } +`; + type StatsTotalListResponse = { transactions: UnifiedListTransactionListResponse['transactions']; meta: ChainTransferTotals | null; @@ -200,6 +221,50 @@ export const unifiedTransactions = { }; }, + useGetFiltered: ( + config: Omit< + QueryHookOptions< + Pick, + PaginatedQueryVariables< + UnifiedListTransactionSorts, + Unified_Transaction_Bool_Exp + > + >, + 'variables' + > & { + variables: PaginatedQueryVariables< + UnifiedListTransactionSorts, + Unified_Transaction_Bool_Exp + > & { where: Unified_Transaction_Bool_Exp }; + } + ) => { + const limit = config.variables.limit ?? QUERY_DEFAULT_LIMIT; + + const result = useQuery< + Pick, + PaginatedQueryVariables< + UnifiedListTransactionSorts, + Unified_Transaction_Bool_Exp + > + >(GET_FILTERED_UNIFIED_TRANSACTIONS, { + ...config, + variables: { + orderBy: config.variables.orderBy ?? { timestamp: 'desc' }, + limit: peekedLimit(limit), + offset: config.variables.offset ?? 0, + where: config.variables.where + } + }); + + let data: UnifiedListTransactionPageResponse | undefined; + if (result.data) { + const page = takePeekedPage(result.data.transactions, limit); + data = { transactions: page.rows, hasNextPage: page.hasNextPage }; + } + + return { ...result, data }; + }, + useGetRecent: ( config?: Omit< QueryHookOptions, diff --git a/src/components/common/network-provider/network-provider.tsx b/src/components/common/network-provider/network-provider.tsx index ea42523..8a464f9 100644 --- a/src/components/common/network-provider/network-provider.tsx +++ b/src/components/common/network-provider/network-provider.tsx @@ -4,8 +4,8 @@ const ENABLE_LOCAL_NETWORK = import.meta.env.VITE_ENABLE_LOCAL_NETWORK === 'true'; const BASE_NETWORKS = { - mainnet: 'https://sqm.quantus.com/v1/graphql', - planck: 'https://sub2.quantus.com/v1/graphql' + mainnet: 'http://127.0.0.1:8080/https://sqm.quantus.com/v1/graphql', + planck: 'http://127.0.0.1:8080/https://sub2.quantus.com/v1/graphql' } as const; const LOCAL_NETWORK = { diff --git a/src/components/common/table-columns/ACCOUNT_COLUMNS.tsx b/src/components/common/table-columns/ACCOUNT_COLUMNS.tsx index 16dc13c..5446674 100644 --- a/src/components/common/table-columns/ACCOUNT_COLUMNS.tsx +++ b/src/components/common/table-columns/ACCOUNT_COLUMNS.tsx @@ -30,7 +30,8 @@ export const ACCOUNT_COLUMNS = [ is_high_security: isHighSec, is_guardian: isGuardian, is_multisig: isMultisig, - is_deposit_only: isDepositOnly + is_deposit_only: isDepositOnly, + minedBlocks } = props.row.original; const hasFlags = isHighSec || isGuardian || isMultisig; const addressLabel = ( @@ -39,6 +40,7 @@ export const ACCOUNT_COLUMNS = [ isGuardian={isGuardian} isMultisig={isMultisig} isDepositOnly={isDepositOnly} + hasMinedBlocks={minedBlocks.length > 0} /> ); diff --git a/src/components/features/account-details/account-all-transactions/AccountAllTransactions.tsx b/src/components/features/account-details/account-all-transactions/AccountAllTransactions.tsx index d8f8e2a..0a600c5 100644 --- a/src/components/features/account-details/account-all-transactions/AccountAllTransactions.tsx +++ b/src/components/features/account-details/account-all-transactions/AccountAllTransactions.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { AccountTypeFilter } from '@/components/ui/composites/account-type-filter/AccountTypeFilter'; import { DataTable } from '@/components/ui/composites/data-table/DataTable'; import { FetchError } from '@/components/ui/composites/fetch-error/FetchError'; @@ -10,12 +11,15 @@ interface Props { } export const AccountAllTransactions: React.FC = ({ accountId }) => { - const { getStatus, table, error } = useAccountAllTransactions(accountId); + const { getStatus, table, error, isFiltered, accountTypeFilters } = + useAccountAllTransactions(accountId); return (

Activity

+ + = ({ accountId }) => { errorFallback: }} withControls + emptyDescription={ + isFiltered ? 'No transactions match the current filters.' : undefined + } />
); diff --git a/src/components/features/account-details/account-all-transactions/hook.tsx b/src/components/features/account-details/account-all-transactions/hook.tsx index fce1a5c..cbca7ea 100644 --- a/src/components/features/account-details/account-all-transactions/hook.tsx +++ b/src/components/features/account-details/account-all-transactions/hook.tsx @@ -1,22 +1,18 @@ import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useEffect, useMemo, useState } from 'react'; -import useApiClient from '@/api'; import { createAccountUnifiedTransactionColumns } from '@/components/common/table-columns/UNIFIED_LIST_TRANSACTION_COLUMNS'; -import { DATA_POOL_INTERVAL } from '@/constants/data-pool-interval'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; import type { UnifiedListTransactionSorts } from '@/constants/query-sorts'; +import { useAccountTypeFilteredTransactions } from '@/hooks/useAccountTypeFilteredTransactions'; import { useBrowsablePageDepth } from '@/hooks/useBrowsablePageDepth'; import { useOrderBy } from '@/hooks/useOrderBy'; import { useTableState } from '@/hooks/useTableState'; import type { UnifiedListTransaction } from '@/schemas'; -import { browsableRowCount } from '@/utils/browsable-page-depth'; import { transformSortLiteral } from '@/utils/transform-sort'; -import { withExcludedRewardTransfers } from '@/utils/unified-transaction-filters'; +import { accountPartyWhere } from '@/utils/unified-transaction-filters'; export const useAccountAllTransactions = (accountId: string) => { - const api = useApiClient(); - const { orderBy, limit, @@ -34,42 +30,31 @@ export const useAccountAllTransactions = (accountId: string) => { handleChangePagination }); - const where = useMemo( - () => - withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: accountId } } }, - { to: { id: { _eq: accountId } } } - ] - }), - [accountId] - ); + const baseWhere = useMemo(() => accountPartyWhere(accountId), [accountId]); const { loading, - data, - error: fetchError - } = api.unifiedTransactions.useGetAll({ - skip: beyondDepth, - pollInterval: DATA_POOL_INTERVAL, - variables: { - orderBy: orderByObject, - limit, - offset: currentPageIndex * limit, - where - } + error: fetchError, + transactions, + latestRowCount, + isFiltered, + accountTypeFilters + } = useAccountTypeFilteredTransactions({ + baseWhere, + orderBy: orderByObject, + limit, + currentPageIndex, + skip: beyondDepth }); const transactionColumns = useMemo( () => createAccountUnifiedTransactionColumns(accountId), [accountId] ); - const [rowCount, setRowCount] = useState( - browsableRowCount(data?.meta.aggregate.totalCount ?? 0) - ); + const [rowCount, setRowCount] = useState(latestRowCount ?? 0); const table = useReactTable({ - data: data?.transactions ?? [], + data: transactions ?? [], columns: transactionColumns, getCoreRowModel: getCoreRowModel(), state: { @@ -77,6 +62,7 @@ export const useAccountAllTransactions = (accountId: string) => { pagination: paginationValue }, rowCount, + meta: { totalCountUnknown: isFiltered }, onSortingChange: handleChangeSorting, onPaginationChange: handleChangePagination, manualSorting: true, @@ -100,13 +86,14 @@ export const useAccountAllTransactions = (accountId: string) => { }; useEffect(() => { - if (!loading && data?.meta.aggregate.totalCount != null) - setRowCount(browsableRowCount(data.meta.aggregate.totalCount)); - }, [loading, data?.meta.aggregate.totalCount]); + if (!loading && latestRowCount != null) setRowCount(latestRowCount); + }, [loading, latestRowCount]); return { table, getStatus, - error: fetchError + error: fetchError, + isFiltered, + accountTypeFilters }; }; diff --git a/src/components/features/account-details/account-information/AccountInformation.tsx b/src/components/features/account-details/account-information/AccountInformation.tsx index 81dd637..79e6bd3 100644 --- a/src/components/features/account-details/account-information/AccountInformation.tsx +++ b/src/components/features/account-details/account-information/AccountInformation.tsx @@ -32,6 +32,7 @@ interface AccountDetailsInfo { isGuardian: boolean; isMultisig: boolean; isDepositOnly: boolean | null; + hasMinedBlocks: boolean; } export const AccountInformation: React.FC = ({ @@ -70,7 +71,8 @@ export const AccountInformation: React.FC = ({ isHighSecurity, isGuardian, isMultisig, - isDepositOnly: account?.is_deposit_only ?? null + isDepositOnly: account?.is_deposit_only ?? null, + hasMinedBlocks: (account?.minedBlocks.length ?? 0) > 0 } ]; @@ -136,6 +138,7 @@ export const AccountInformation: React.FC = ({ isGuardian={item.isGuardian} isMultisig={item.isMultisig} isDepositOnly={item.isDepositOnly} + hasMinedBlocks={item.hasMinedBlocks} /> ); if (badges.length === 0) { diff --git a/src/components/features/account-listing/accounts-table/AccountsTable.tsx b/src/components/features/account-listing/accounts-table/AccountsTable.tsx index 5494383..c347136 100644 --- a/src/components/features/account-listing/accounts-table/AccountsTable.tsx +++ b/src/components/features/account-listing/accounts-table/AccountsTable.tsx @@ -1,19 +1,28 @@ +import { AccountTypeFilter } from '@/components/ui/composites/account-type-filter/AccountTypeFilter'; import { DataTable } from '@/components/ui/composites/data-table/DataTable'; import { FetchError } from '@/components/ui/composites/fetch-error/FetchError'; import { useAccountsTable } from './hook'; export const AccountsTable = () => { - const { getStatus, table, error } = useAccountsTable(); + const { getStatus, table, error, isFiltered, accountTypeFilters } = + useAccountsTable(); return ( - - }} - withControls - /> +
+ + + + }} + withControls + emptyDescription={ + isFiltered ? 'No accounts match the current filters.' : undefined + } + /> +
); }; diff --git a/src/components/features/account-listing/accounts-table/hook.tsx b/src/components/features/account-listing/accounts-table/hook.tsx index 79bf027..eddc498 100644 --- a/src/components/features/account-listing/accounts-table/hook.tsx +++ b/src/components/features/account-listing/accounts-table/hook.tsx @@ -3,13 +3,19 @@ import { useEffect, useMemo, useState } from 'react'; import useApiClient from '@/api'; import { ACCOUNT_COLUMNS } from '@/components/common/table-columns/ACCOUNT_COLUMNS'; +import type { AccountTypeFilterKey } from '@/constants/account-types'; import { DATA_POOL_INTERVAL } from '@/constants/data-pool-interval'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; import type { AccountSorts } from '@/constants/query-sorts'; +import { useAccountTypeFilters } from '@/hooks/useAccountTypeFilters'; import { useOrderBy } from '@/hooks/useOrderBy'; import { useTableState } from '@/hooks/useTableState'; import type { AccountListItem } from '@/schemas'; +import { accountTypeRuleWhere } from '@/utils/account-type-filter'; import { transformSortLiteral } from '@/utils/transform-sort'; +import { uncountedRowCount } from '@/utils/uncounted-pagination'; + +const ACCOUNT_FILTER_KEYS: readonly AccountTypeFilterKey[] = ['account_type']; export const useAccountsTable = () => { const api = useApiClient(); @@ -21,29 +27,61 @@ export const useAccountsTable = () => { handleChangePagination, paginationValue } = useTableState('last_updated:desc', QUERY_DEFAULT_LIMIT); + const accountTypeFilters = useAccountTypeFilters(ACCOUNT_FILTER_KEYS); + const accountTypeRule = accountTypeFilters.filters.account_type; const orderByObject = useOrderBy( orderBy ?? 'last_updated:desc' ); const sortingValue = transformSortLiteral(orderBy); - const { - loading, - data, - error: fetchError - } = api.accounts.useGetAll({ + const where = useMemo( + () => (accountTypeRule ? accountTypeRuleWhere(accountTypeRule) : null), + [accountTypeRule] + ); + const isFiltered = where !== null; + + const variables = { + orderBy: orderByObject, + limit, + offset: currentPageIndex * limit + }; + + const unfiltered = api.accounts.useGetAll({ + skip: isFiltered, pollInterval: DATA_POOL_INTERVAL, - variables: { - orderBy: orderByObject, - limit, - offset: currentPageIndex * limit - } + variables + }); + const filtered = api.accounts.useGetFiltered({ + skip: !isFiltered, + pollInterval: DATA_POOL_INTERVAL, + variables: { ...variables, where: where ?? {} } }); + + const { loading, error: fetchError } = isFiltered ? filtered : unfiltered; + const accounts = isFiltered + ? filtered.data?.accounts + : unfiltered.data?.accounts; + + let latestRowCount: number | undefined; + if (isFiltered) { + latestRowCount = filtered.data + ? uncountedRowCount({ + pageIndex: currentPageIndex, + limit, + pageRowCount: filtered.data.accounts.length, + hasNextPage: filtered.data.hasNextPage + }) + : undefined; + } else { + latestRowCount = unfiltered.data?.meta.totalCount; + } + const accountColumns = useMemo(() => ACCOUNT_COLUMNS, []); - const [rowCount, setRowCount] = useState(data?.meta.totalCount ?? 0); + const [rowCount, setRowCount] = useState(latestRowCount ?? 0); const table = useReactTable({ - data: data?.accounts ?? [], + data: accounts ?? [], columns: accountColumns, getCoreRowModel: getCoreRowModel(), state: { @@ -51,6 +89,7 @@ export const useAccountsTable = () => { pagination: paginationValue }, rowCount, + meta: { totalCountUnknown: isFiltered }, onSortingChange: handleChangeSorting, onPaginationChange: handleChangePagination, manualSorting: true, @@ -74,12 +113,14 @@ export const useAccountsTable = () => { }; useEffect(() => { - if (!loading && data?.meta.totalCount) setRowCount(data.meta.totalCount); - }, [loading, data?.meta.totalCount]); + if (!loading && latestRowCount != null) setRowCount(latestRowCount); + }, [loading, latestRowCount]); return { table, getStatus, - error + error, + isFiltered, + accountTypeFilters }; }; diff --git a/src/components/features/multisig-details/multisig-transactions/hook.tsx b/src/components/features/multisig-details/multisig-transactions/hook.tsx index 4e3e6f8..bb62923 100644 --- a/src/components/features/multisig-details/multisig-transactions/hook.tsx +++ b/src/components/features/multisig-details/multisig-transactions/hook.tsx @@ -12,7 +12,10 @@ import { useTableState } from '@/hooks/useTableState'; import type { UnifiedListTransaction } from '@/schemas'; import { browsableRowCount } from '@/utils/browsable-page-depth'; import { transformSortLiteral } from '@/utils/transform-sort'; -import { withExcludedRewardTransfers } from '@/utils/unified-transaction-filters'; +import { + accountPartyWhere, + withExcludedRewardTransfers +} from '@/utils/unified-transaction-filters'; export const useMultisigDetailTransactions = (walletId: string) => { const api = useApiClient(); @@ -35,13 +38,7 @@ export const useMultisigDetailTransactions = (walletId: string) => { }); const where = useMemo( - () => - withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: walletId } } }, - { to: { id: { _eq: walletId } } } - ] - }), + () => withExcludedRewardTransfers(accountPartyWhere(walletId)), [walletId] ); diff --git a/src/components/features/transaction-listing/transactions-table/TransactionsTable.tsx b/src/components/features/transaction-listing/transactions-table/TransactionsTable.tsx index 2967f1c..8544859 100644 --- a/src/components/features/transaction-listing/transactions-table/TransactionsTable.tsx +++ b/src/components/features/transaction-listing/transactions-table/TransactionsTable.tsx @@ -1,21 +1,30 @@ import React from 'react'; +import { AccountTypeFilter } from '@/components/ui/composites/account-type-filter/AccountTypeFilter'; import { DataTable } from '@/components/ui/composites/data-table/DataTable'; import { FetchError } from '@/components/ui/composites/fetch-error/FetchError'; import { useTransactionsTable } from './hook'; export const TransactionsTable = () => { - const { getStatus, table, error } = useTransactionsTable(); + const { getStatus, table, error, isFiltered, accountTypeFilters } = + useTransactionsTable(); return ( - - }} - withControls - /> +
+ + + + }} + withControls + emptyDescription={ + isFiltered ? 'No transactions match the current filters.' : undefined + } + /> +
); }; diff --git a/src/components/features/transaction-listing/transactions-table/hook.tsx b/src/components/features/transaction-listing/transactions-table/hook.tsx index 741333a..fb8a53e 100644 --- a/src/components/features/transaction-listing/transactions-table/hook.tsx +++ b/src/components/features/transaction-listing/transactions-table/hook.tsx @@ -2,21 +2,19 @@ import { useSearch } from '@tanstack/react-router'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useEffect, useMemo, useState } from 'react'; -import useApiClient from '@/api'; +import type { Unified_Transaction_Bool_Exp } from '@/__generated__/graphql'; import { UNIFIED_LIST_TRANSACTION_COLUMNS } from '@/components/common/table-columns/UNIFIED_LIST_TRANSACTION_COLUMNS'; -import { DATA_POOL_INTERVAL } from '@/constants/data-pool-interval'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; import type { UnifiedListTransactionSorts } from '@/constants/query-sorts'; +import { useAccountTypeFilteredTransactions } from '@/hooks/useAccountTypeFilteredTransactions'; import { useBrowsablePageDepth } from '@/hooks/useBrowsablePageDepth'; import { useOrderBy } from '@/hooks/useOrderBy'; import { useTableState } from '@/hooks/useTableState'; import type { UnifiedListTransaction } from '@/schemas'; -import { browsableRowCount } from '@/utils/browsable-page-depth'; import { transformSortLiteral } from '@/utils/transform-sort'; -import { withExcludedRewardTransfers } from '@/utils/unified-transaction-filters'; +import { accountPartyWhere } from '@/utils/unified-transaction-filters'; export const useTransactionsTable = () => { - const api = useApiClient(); const { accountId, block } = useSearch({ strict: false }) as { accountId?: string; block?: string }; @@ -38,48 +36,39 @@ export const useTransactionsTable = () => { handleChangePagination }); - const where = useMemo(() => { + const baseWhere = useMemo(() => { if (accountId) { - return withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: accountId } } }, - { to: { id: { _eq: accountId } } } - ] - }); + return accountPartyWhere(accountId); } if (block) { - return withExcludedRewardTransfers({ - block_height: { _eq: Number(block) } - }); + return { block_height: { _eq: Number(block) } }; } - return withExcludedRewardTransfers(); + return undefined; }, [accountId, block]); const { loading, - data, - error: fetchError - } = api.unifiedTransactions.useGetAll({ - skip: beyondDepth, - pollInterval: DATA_POOL_INTERVAL, - variables: { - orderBy: orderByObject, - limit, - offset: currentPageIndex * limit, - where - } + error: fetchError, + transactions, + latestRowCount, + isFiltered, + accountTypeFilters + } = useAccountTypeFilteredTransactions({ + baseWhere, + orderBy: orderByObject, + limit, + currentPageIndex, + skip: beyondDepth }); const transactionColumns = useMemo( () => UNIFIED_LIST_TRANSACTION_COLUMNS, [] ); - const [rowCount, setRowCount] = useState( - browsableRowCount(data?.meta.aggregate.totalCount ?? 0) - ); + const [rowCount, setRowCount] = useState(latestRowCount ?? 0); const table = useReactTable({ - data: data?.transactions ?? [], + data: transactions ?? [], columns: transactionColumns, getCoreRowModel: getCoreRowModel(), state: { @@ -87,6 +76,7 @@ export const useTransactionsTable = () => { pagination: paginationValue }, rowCount, + meta: { totalCountUnknown: isFiltered }, onSortingChange: handleChangeSorting, onPaginationChange: handleChangePagination, manualSorting: true, @@ -110,13 +100,14 @@ export const useTransactionsTable = () => { }; useEffect(() => { - if (!loading && data?.meta.aggregate.totalCount != null) - setRowCount(browsableRowCount(data.meta.aggregate.totalCount)); - }, [loading, data?.meta.aggregate.totalCount]); + if (!loading && latestRowCount != null) setRowCount(latestRowCount); + }, [loading, latestRowCount]); return { table, getStatus, - error + error, + isFiltered, + accountTypeFilters }; }; diff --git a/src/components/ui/composites/account-type-filter/AccountTypeFilter.tsx b/src/components/ui/composites/account-type-filter/AccountTypeFilter.tsx new file mode 100644 index 0000000..9e2c780 --- /dev/null +++ b/src/components/ui/composites/account-type-filter/AccountTypeFilter.tsx @@ -0,0 +1,286 @@ +import { Check, ChevronDown, ListFilter, Plus, Trash2 } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + ACCOUNT_TYPE_FILTER_LABELS, + ACCOUNT_TYPE_LABELS, + ACCOUNT_TYPES, + type AccountTypeFilterKey, + type AccountTypeFilterRule, + FILTER_OPERATOR_LABELS, + FILTER_OPERATORS, + type FilterOperator +} from '@/constants/account-types'; +import type { AccountTypeFiltersState } from '@/hooks/useAccountTypeFilters'; +import { cn } from '@/lib/utils'; +import { toggleRuleType } from '@/utils/account-type-filter'; + +const MAX_PILL_TYPE_LABELS = 2; + +const CONTROL_CLASS = + 'h-8 gap-1.5 rounded-none border-border-strong bg-transparent px-2.5 font-mono text-[11px] tracking-[0.04em] text-muted-text shadow-none hover:border-flare hover:bg-transparent hover:text-content [&_svg]:size-3'; + +const MENU_ITEM_CLASS = + 'flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-left font-mono text-[11px] tracking-[0.04em] text-content outline-none hover:bg-surface-2 focus-visible:bg-surface-2'; + +function pillValueText(rule: AccountTypeFilterRule): string { + const labels = rule.types.map((type) => ACCOUNT_TYPE_LABELS[type]); + const shown = labels.slice(0, MAX_PILL_TYPE_LABELS).join(', '); + const hiddenCount = labels.length - MAX_PILL_TYPE_LABELS; + return hiddenCount > 0 ? `${shown} +${hiddenCount}` : shown; +} + +interface AddFilterMenuProps { + keys: readonly AccountTypeFilterKey[]; + isFirst: boolean; + onSelect: (key: AccountTypeFilterKey) => void; +} + +const AddFilterMenu: React.FC = ({ + keys, + isFirst, + onSelect +}) => { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + event.preventDefault()} + > +

+ Filter by +

+ {keys.map((key) => ( + + ))} +
+
+ ); +}; + +interface FilterPillProps { + filterKey: AccountTypeFilterKey; + rule: AccountTypeFilterRule; + open: boolean; + onOpenChange: (open: boolean) => void; + onChange: (rule: AccountTypeFilterRule) => void; + onRemove: () => void; +} + +const FilterPill: React.FC = ({ + filterKey, + rule, + open, + onOpenChange, + onChange, + onRemove +}) => { + const label = ACCOUNT_TYPE_FILTER_LABELS[filterKey]; + const isActive = rule.types.length > 0; + + return ( + + + + + + +
+ + {label} + + + + + +
+ +
+ {ACCOUNT_TYPES.map((type) => { + const checked = rule.types.includes(type); + return ( + + ); + })} +
+
+
+ ); +}; + +export interface AccountTypeFilterProps { + state: AccountTypeFiltersState; +} + +export const AccountTypeFilter: React.FC = ({ + state +}) => { + const { keys, filters, addFilter, updateFilter, removeFilter, clearFilters } = + state; + const [openKey, setOpenKey] = React.useState( + null + ); + + const activeKeys = keys.filter((key) => filters[key]); + const availableKeys = keys.filter((key) => !filters[key]); + + return ( +
+ {activeKeys.map((key) => ( + setOpenKey(open ? key : null)} + onChange={(rule) => updateFilter(key, rule)} + onRemove={() => { + setOpenKey(null); + removeFilter(key); + }} + /> + ))} + + {availableKeys.length > 0 && ( + { + addFilter(key); + setOpenKey(key); + }} + /> + )} + + {activeKeys.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/components/ui/composites/data-table/TableControl.tsx b/src/components/ui/composites/data-table/TableControl.tsx index ba728a9..b5f8082 100644 --- a/src/components/ui/composites/data-table/TableControl.tsx +++ b/src/components/ui/composites/data-table/TableControl.tsx @@ -31,6 +31,7 @@ export const TableControls: React.FC = ({ }) => { const { pageSize, pageIndex } = table.getState().pagination; const pageCount = table.getPageCount(); + const totalCountUnknown = table.options.meta?.totalCountUnknown ?? false; const currentPage = pageIndex + 1; const [page, setPage] = React.useState(currentPage); @@ -114,7 +115,7 @@ export const TableControls: React.FC = ({ disabled={pageCount <= 1} /> - of {pageCount} + {!totalCountUnknown && of {pageCount}} diff --git a/src/constants/account-types.ts b/src/constants/account-types.ts new file mode 100644 index 0000000..12ea9f2 --- /dev/null +++ b/src/constants/account-types.ts @@ -0,0 +1,51 @@ +export const ACCOUNT_TYPES = [ + 'transparent', + 'potential_encrypted', + 'encrypted', + 'high_security', + 'guardian', + 'multisig' +] as const; +export type AccountType = (typeof ACCOUNT_TYPES)[number]; + +export const ACCOUNT_TYPE_LABELS: Record = { + transparent: 'Transparent', + potential_encrypted: 'Potential Encrypted', + encrypted: 'Encrypted', + high_security: 'High Security', + guardian: 'Guardian', + multisig: 'Multisig' +}; + +export const FILTER_OPERATORS = ['is', 'is_not'] as const; +export type FilterOperator = (typeof FILTER_OPERATORS)[number]; + +export const FILTER_OPERATOR_LABELS: Record = { + is: 'is', + is_not: 'is not' +}; + +/** An empty `types` list is a filter that was added but does not narrow results yet. */ +export interface AccountTypeFilterRule { + operator: FilterOperator; + types: readonly AccountType[]; +} + +/** Filterable account-type properties; each key is also its URL search param. */ +export const ACCOUNT_TYPE_FILTER_KEYS = [ + 'account_type', + 'from_type', + 'to_type' +] as const; +export type AccountTypeFilterKey = (typeof ACCOUNT_TYPE_FILTER_KEYS)[number]; + +export const ACCOUNT_TYPE_FILTER_LABELS: Record = + { + account_type: 'Account type', + from_type: 'From account type', + to_type: 'To account type' + }; + +export type AccountTypeFilters = Partial< + Record +>; diff --git a/src/hooks/useAccountTypeFilteredTransactions.ts b/src/hooks/useAccountTypeFilteredTransactions.ts new file mode 100644 index 0000000..206c31a --- /dev/null +++ b/src/hooks/useAccountTypeFilteredTransactions.ts @@ -0,0 +1,103 @@ +import { useMemo } from 'react'; + +import type { Unified_Transaction_Bool_Exp } from '@/__generated__/graphql'; +import useApiClient from '@/api'; +import type { AccountTypeFilterKey } from '@/constants/account-types'; +import { DATA_POOL_INTERVAL } from '@/constants/data-pool-interval'; +import type { UnifiedListTransactionSorts } from '@/constants/query-sorts'; +import { useAccountTypeFilters } from '@/hooks/useAccountTypeFilters'; +import { accountTypeFilteredTransactionsWhere } from '@/utils/account-type-filter'; +import { browsableRowCount } from '@/utils/browsable-page-depth'; +import { uncountedRowCount } from '@/utils/uncounted-pagination'; +import { withExcludedRewardTransfers } from '@/utils/unified-transaction-filters'; + +interface Args { + /** Table-specific filter (account party, block, ...); undefined lists every transaction. */ + baseWhere?: Unified_Transaction_Bool_Exp; + orderBy?: UnifiedListTransactionSorts; + limit: number; + currentPageIndex: number; + skip: boolean; +} + +const TRANSACTION_FILTER_KEYS: readonly AccountTypeFilterKey[] = [ + 'from_type', + 'to_type' +]; + +/** + * Unified transactions for a table with the sender / receiver account-type filters applied. + * Unfiltered lists keep their fast precomputed totals; filtered lists page + * without a total (see `uncounted-pagination.ts`). + */ +export const useAccountTypeFilteredTransactions = ({ + baseWhere, + orderBy, + limit, + currentPageIndex, + skip +}: Args) => { + const api = useApiClient(); + const accountTypeFilters = useAccountTypeFilters(TRANSACTION_FILTER_KEYS); + const { filters } = accountTypeFilters; + + const unfilteredWhere = useMemo( + () => withExcludedRewardTransfers(baseWhere), + [baseWhere] + ); + const filteredWhere = useMemo( + () => + accountTypeFilteredTransactionsWhere(baseWhere, { + from: filters.from_type, + to: filters.to_type + }), + [baseWhere, filters.from_type, filters.to_type] + ); + const isFiltered = filteredWhere !== null; + + const variables = { + orderBy, + limit, + offset: currentPageIndex * limit + }; + + const unfiltered = api.unifiedTransactions.useGetAll({ + skip: skip || isFiltered, + pollInterval: DATA_POOL_INTERVAL, + variables: { ...variables, where: unfilteredWhere } + }); + const filtered = api.unifiedTransactions.useGetFiltered({ + skip: skip || !isFiltered, + pollInterval: DATA_POOL_INTERVAL, + variables: { ...variables, where: filteredWhere ?? unfilteredWhere } + }); + + let latestRowCount: number | undefined; + if (isFiltered) { + latestRowCount = filtered.data + ? browsableRowCount( + uncountedRowCount({ + pageIndex: currentPageIndex, + limit, + pageRowCount: filtered.data.transactions.length, + hasNextPage: filtered.data.hasNextPage + }) + ) + : undefined; + } else if (unfiltered.data?.meta.aggregate.totalCount != null) { + latestRowCount = browsableRowCount( + unfiltered.data.meta.aggregate.totalCount + ); + } + + const active = isFiltered ? filtered : unfiltered; + + return { + loading: active.loading, + error: active.error, + transactions: active.data?.transactions, + latestRowCount, + isFiltered, + accountTypeFilters + }; +}; diff --git a/src/hooks/useAccountTypeFilters.ts b/src/hooks/useAccountTypeFilters.ts new file mode 100644 index 0000000..16243ed --- /dev/null +++ b/src/hooks/useAccountTypeFilters.ts @@ -0,0 +1,88 @@ +import { + createParser, + parseAsInteger, + useQueryState, + useQueryStates +} from 'nuqs'; +import { useCallback, useMemo } from 'react'; + +import type { + AccountTypeFilterKey, + AccountTypeFilterRule, + AccountTypeFilters +} from '@/constants/account-types'; +import { + parseAccountTypeFilterRule, + serializeAccountTypeFilterRule +} from '@/utils/account-type-filter'; + +const ruleParser = createParser({ + parse: parseAccountTypeFilterRule, + serialize: serializeAccountTypeFilterRule, + eq: (a, b) => + serializeAccountTypeFilterRule(a) === serializeAccountTypeFilterRule(b) +}); + +const EMPTY_RULE: AccountTypeFilterRule = { operator: 'is', types: [] }; + +/** + * Notion-style account-type filters for one table, kept in the URL so filtered + * views are shareable. `keys` must be a stable (module-level) array. + */ +export const useAccountTypeFilters = ( + keys: readonly AccountTypeFilterKey[] +) => { + const keyMap = useMemo( + () => Object.fromEntries(keys.map((key) => [key, ruleParser])), + [keys] + ); + const [values, setValues] = useQueryStates(keyMap); + const [, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + + const filters = useMemo(() => { + const active: AccountTypeFilters = {}; + for (const key of keys) { + const rule = values[key]; + if (rule) active[key] = rule; + } + return active; + }, [keys, values]); + + // An empty rule does not narrow results, so adding one keeps the current page. + const addFilter = useCallback( + (key: AccountTypeFilterKey) => setValues({ [key]: EMPTY_RULE }), + [setValues] + ); + + const updateFilter = useCallback( + (key: AccountTypeFilterKey, rule: AccountTypeFilterRule) => { + setPage(1); + setValues({ [key]: rule }); + }, + [setPage, setValues] + ); + + const removeFilter = useCallback( + (key: AccountTypeFilterKey) => { + setPage(1); + setValues({ [key]: null }); + }, + [setPage, setValues] + ); + + const clearFilters = useCallback(() => { + setPage(1); + setValues(Object.fromEntries(keys.map((key) => [key, null]))); + }, [keys, setPage, setValues]); + + return { + keys, + filters, + addFilter, + updateFilter, + removeFilter, + clearFilters + }; +}; + +export type AccountTypeFiltersState = ReturnType; diff --git a/src/lib/graphql-benchmark/account-type-filter-registry.ts b/src/lib/graphql-benchmark/account-type-filter-registry.ts new file mode 100644 index 0000000..fc0a475 --- /dev/null +++ b/src/lib/graphql-benchmark/account-type-filter-registry.ts @@ -0,0 +1,221 @@ +import type { + Account_Bool_Exp, + Unified_Transaction_Bool_Exp +} from '@/__generated__/graphql'; +import { GET_FILTERED_ACCOUNTS } from '@/api/accounts'; +import { + GET_FILTERED_UNIFIED_TRANSACTIONS, + GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL, + GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL +} from '@/api/unified-transactions'; +import { + ACCOUNT_TYPES, + type AccountType, + type AccountTypeFilterRule +} from '@/constants/account-types'; +import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; +import { + accountTypeFilteredTransactionsWhere, + accountTypeRuleWhere +} from '@/utils/account-type-filter'; +import { maxBrowsablePageIndex } from '@/utils/browsable-page-depth'; +import { peekedLimit } from '@/utils/uncounted-pagination'; +import { + accountPartyWhere, + EXCLUDE_REWARD_TRANSFERS, + withExcludedRewardTransfers +} from '@/utils/unified-transaction-filters'; + +import type { + GraphqlBenchmarkContext, + GraphqlBenchmarkRegistryEntry +} from './types'; + +export const ACCOUNT_TYPE_FILTER_GROUP = 'account-type-filters'; + +/** Offset of the deepest page the transaction pager lets a user reach. */ +const DEEPEST_PAGE_OFFSET = + maxBrowsablePageIndex(QUERY_DEFAULT_LIMIT) * QUERY_DEFAULT_LIMIT; + +const is = (...types: AccountType[]): AccountTypeFilterRule => ({ + operator: 'is', + types +}); +const isNot = (...types: AccountType[]): AccountTypeFilterRule => ({ + operator: 'is_not', + types +}); + +type PartyRules = { from?: AccountTypeFilterRule; to?: AccountTypeFilterRule }; + +function filteredTransactionsVariables( + rules: PartyRules, + options: { baseWhere?: Unified_Transaction_Bool_Exp; offset?: number } = {} +) { + const where = accountTypeFilteredTransactionsWhere(options.baseWhere, rules); + if (!where) { + throw new Error( + `Benchmark rules do not narrow results: ${JSON.stringify(rules)}` + ); + } + return { + orderBy: { timestamp: 'desc' }, + limit: peekedLimit(QUERY_DEFAULT_LIMIT), + offset: options.offset ?? 0, + where + }; +} + +function filteredAccountsVariables(rule: AccountTypeFilterRule) { + const where: Account_Bool_Exp | null = accountTypeRuleWhere(rule); + if (!where) { + throw new Error( + `Benchmark rule does not narrow results: ${JSON.stringify(rule)}` + ); + } + return { + orderBy: { id: 'desc' }, + limit: peekedLimit(QUERY_DEFAULT_LIMIT), + offset: 0, + where + }; +} + +function filteredTransactions( + label: string, + rules: PartyRules, + offset?: number +): GraphqlBenchmarkRegistryEntry { + return { + name: `GetFilteredUnifiedTransactions ${label}`, + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_FILTERED_UNIFIED_TRANSACTIONS, + getVariables: () => filteredTransactionsVariables(rules, { offset }) + }; +} + +function filteredAccounts( + label: string, + rule: AccountTypeFilterRule +): GraphqlBenchmarkRegistryEntry { + return { + name: `GetFilteredAccounts ${label}`, + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_FILTERED_ACCOUNTS, + getVariables: () => filteredAccountsVariables(rule) + }; +} + +type PartySample = { + label: string; + accountId: (ctx: GraphqlBenchmarkContext) => string | undefined; +}; + +/** A quiet account is the worst case for a timestamp-index walk; busy and miner accounts for per-account index walks. */ +const PARTY_SAMPLES: PartySample[] = [ + { label: 'quiet account', accountId: (ctx) => ctx.quietAccountId }, + { label: 'busiest account', accountId: (ctx) => ctx.busyAccountId }, + { label: 'top miner', accountId: (ctx) => ctx.minerAccountId } +]; + +function accountPartyEntries( + sample: PartySample +): GraphqlBenchmarkRegistryEntry[] { + const accountTotal = (offset: number) => (ctx: GraphqlBenchmarkContext) => { + const accountId = sample.accountId(ctx); + if (!accountId) return null; + return { + orderBy: { timestamp: 'desc' }, + limit: QUERY_DEFAULT_LIMIT, + offset, + where: withExcludedRewardTransfers(accountPartyWhere(accountId)), + accountId + }; + }; + return [ + { + name: `GetUnifiedTransactionsWithAccountTotal account party (${sample.label})`, + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL, + getVariables: accountTotal(0) + }, + { + name: `GetUnifiedTransactionsWithAccountTotal account party, deepest page (${sample.label})`, + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_UNIFIED_TRANSACTIONS_WITH_ACCOUNT_TOTAL, + getVariables: accountTotal(DEEPEST_PAGE_OFFSET) + }, + { + name: `GetFilteredUnifiedTransactions account party, to is not transparent (${sample.label})`, + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_FILTERED_UNIFIED_TRANSACTIONS, + getVariables: (ctx) => { + const accountId = sample.accountId(ctx); + if (!accountId) return null; + return filteredTransactionsVariables( + { to: isNot('transparent') }, + { baseWhere: accountPartyWhere(accountId) } + ); + } + } + ]; +} + +/** + * Account-type filtered lists next to the unfiltered queries they replace. + * Sparse and zero-match rules are the worst case: the database walks the whole + * timestamp-ordered list looking for rows that never fill the page. + */ +export const accountTypeFilterBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = + [ + { + name: 'GetUnifiedTransactionsWithChainTotal unfiltered', + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL, + getVariables: () => ({ + orderBy: { timestamp: 'desc' }, + limit: QUERY_DEFAULT_LIMIT, + offset: 0, + where: EXCLUDE_REWARD_TRANSFERS + }) + }, + { + name: 'GetUnifiedTransactionsWithChainTotal unfiltered, deepest page', + group: ACCOUNT_TYPE_FILTER_GROUP, + document: GET_UNIFIED_TRANSACTIONS_WITH_CHAIN_TOTAL, + getVariables: () => ({ + orderBy: { timestamp: 'desc' }, + limit: QUERY_DEFAULT_LIMIT, + offset: DEEPEST_PAGE_OFFSET, + where: EXCLUDE_REWARD_TRANSFERS + }) + }, + filteredTransactions('from is transparent', { from: is('transparent') }), + filteredTransactions('from is not transparent', { + from: isNot('transparent') + }), + filteredTransactions('from is potential_encrypted', { + from: is('potential_encrypted') + }), + filteredTransactions('from is encrypted', { from: is('encrypted') }), + filteredTransactions('from is multisig', { from: is('multisig') }), + filteredTransactions('to is guardian', { to: is('guardian') }), + filteredTransactions('from is multisig, to is guardian', { + from: is('multisig'), + to: is('guardian') + }), + filteredTransactions('from is any type', { from: is(...ACCOUNT_TYPES) }), + filteredTransactions( + 'from is transparent, deepest page', + { from: is('transparent') }, + DEEPEST_PAGE_OFFSET + ), + filteredTransactions( + 'from is encrypted, deepest page', + { from: is('encrypted') }, + DEEPEST_PAGE_OFFSET + ), + ...PARTY_SAMPLES.flatMap(accountPartyEntries), + ...ACCOUNT_TYPES.map((type) => filteredAccounts(`is ${type}`, is(type))), + filteredAccounts('is not transparent', isNot('transparent')) + ]; diff --git a/src/lib/graphql-benchmark/bootstrap.ts b/src/lib/graphql-benchmark/bootstrap.ts index cf8e87e..eb8f2f6 100644 --- a/src/lib/graphql-benchmark/bootstrap.ts +++ b/src/lib/graphql-benchmark/bootstrap.ts @@ -1,7 +1,10 @@ -import type { ApolloClient, NormalizedCacheObject } from '@apollo/client'; +import { + type ApolloClient, + gql, + type NormalizedCacheObject +} from '@apollo/client'; import { - GetAccountsDocument, GetBlockByIdDocument, GetBlocksDocument, GetCancelledReversibleTransactionsDocument, @@ -14,7 +17,9 @@ import { GetWormholeExtrinsicsDocument, Order_By } from '@/__generated__/graphql'; +import { GET_ACCOUNTS } from '@/api/accounts'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; +import type { AccountListResponse } from '@/schemas'; import type { GraphqlBenchmarkContext } from './types'; @@ -48,8 +53,8 @@ export async function loadGraphqlBenchmarkContext( } const accounts = await safeQuery(() => - client.query({ - query: GetAccountsDocument, + client.query({ + query: GET_ACCOUNTS, variables: { orderBy: { id: Order_By.Asc }, limit: 1, @@ -62,6 +67,54 @@ export async function loadGraphqlBenchmarkContext( ctx.accountId = firstAccount.id; } + const partySamples = await safeQuery(() => + client.query<{ + busiest: { id: string; total_immediate_transfers: number }[]; + quietest: { id: string }[]; + topMiner: { id: string; total_mined_blocks: number }[]; + }>({ + query: gql` + query AccountPartySamples { + busiest: account_stats( + limit: 1 + order_by: { total_immediate_transfers: desc } + ) { + id + total_immediate_transfers + } + quietest: account_stats( + limit: 1 + where: { total_immediate_transfers: { _gt: 0 } } + order_by: [{ total_immediate_transfers: asc }, { id: asc }] + ) { + id + } + topMiner: account_stats( + limit: 1 + order_by: { total_mined_blocks: desc } + ) { + id + total_mined_blocks + } + } + ` + }) + ); + const busiest = partySamples?.data?.busiest?.[0]; + if (busiest) { + ctx.busyAccountId = busiest.id; + ctx.busyImmediateTransfers = busiest.total_immediate_transfers; + } + const quietest = partySamples?.data?.quietest?.[0]; + if (quietest) { + ctx.quietAccountId = quietest.id; + } + const topMiner = partySamples?.data?.topMiner?.[0]; + if (topMiner) { + ctx.minerAccountId = topMiner.id; + ctx.minerMinedBlocks = topMiner.total_mined_blocks; + } + const transfers = await safeQuery(() => client.query({ query: GetTransactionsDocument, diff --git a/src/lib/graphql-benchmark/explain.test.ts b/src/lib/graphql-benchmark/explain.test.ts new file mode 100644 index 0000000..682f9cf --- /dev/null +++ b/src/lib/graphql-benchmark/explain.test.ts @@ -0,0 +1,163 @@ +import { gql } from '@apollo/client'; + +import { explainGraphqlOperation, formatExplainReport } from './explain'; +import { selectBenchmarkEntries } from './run'; + +const document = gql` + query Accounts($limit: Int) { + account(limit: $limit) { + id + } + } +`; + +function fakeFetch(status: number, body: unknown) { + const calls: { url: string; init: RequestInit }[] = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return new Response(JSON.stringify(body), { status }); + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +describe('explainGraphqlOperation', () => { + it('posts the operation to the explain endpoint as admin and returns the plans', async () => { + const plans = [{ field: 'account', sql: 'SELECT 1', plan: ['Limit'] }]; + const { calls, fetchImpl } = fakeFetch(200, plans); + + await expect( + explainGraphqlOperation({ + endpoint: 'https://indexer.test/v1/graphql', + adminSecret: 'secret', + document, + variables: { limit: 2 }, + fetchImpl + }) + ).resolves.toEqual(plans); + + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://indexer.test/v1/graphql/explain'); + expect( + (calls[0]!.init.headers as Record)[ + 'x-hasura-admin-secret' + ] + ).toBe('secret'); + const body = JSON.parse(calls[0]!.init.body as string); + expect(body.query.variables).toEqual({ limit: 2 }); + expect(body.query.query).toContain('account(limit: $limit)'); + }); + + it('reports the Hasura error when the request is rejected', async () => { + const { fetchImpl } = fakeFetch(400, { + error: 'You have to be an admin to access this endpoint', + code: 'access-denied' + }); + + await expect( + explainGraphqlOperation({ + endpoint: 'https://indexer.test/v1/graphql', + adminSecret: 'wrong', + document, + variables: {}, + fetchImpl + }) + ).rejects.toThrow( + 'explain failed (400): You have to be an admin to access this endpoint' + ); + }); + + it('rejects a response that is not a list of plans', async () => { + const { fetchImpl } = fakeFetch(200, { data: {} }); + + await expect( + explainGraphqlOperation({ + endpoint: 'https://indexer.test/v1/graphql', + adminSecret: 'secret', + document, + variables: {}, + fetchImpl + }) + ).rejects.toThrow('explain returned an unexpected body'); + }); + + it('refuses to run without an admin secret', async () => { + const { calls, fetchImpl } = fakeFetch(200, []); + + await expect( + explainGraphqlOperation({ + endpoint: 'https://indexer.test/v1/graphql', + adminSecret: '', + document, + variables: {}, + fetchImpl + }) + ).rejects.toThrow('admin secret'); + expect(calls).toHaveLength(0); + }); +}); + +describe('formatExplainReport', () => { + const report = formatExplainReport({ + endpoint: 'https://indexer.test/v1/graphql', + suite: 'explorer', + group: 'account-type-filters', + generatedAt: new Date('2026-10-01T10:00:00Z'), + bootstrapContext: { accountId: 'qz1' }, + bootstrapRequestFailures: ['BusyAccounts: timed out'], + results: [ + { + name: 'GetFilteredAccounts is encrypted', + group: 'account-type-filters', + variables: { limit: 26 }, + plans: [ + { + field: 'accounts', + sql: 'SELECT 1', + plan: ['Limit (cost=0.28..12.37)', ' -> Seq Scan on block'] + } + ] + }, + { name: 'GetAccountById', skipped: true, skipReason: 'No sample id' }, + { name: 'GetBlocks', variables: {}, errorMessage: 'explain failed (400)' } + ] + }); + + it('records where and when the plans were taken', () => { + expect(report).toContain('https://indexer.test/v1/graphql'); + expect(report).toContain('2026-10-01T10:00:00.000Z'); + expect(report).toContain('account-type-filters'); + expect(report).toContain('"accountId": "qz1"'); + expect(report).toContain('BusyAccounts: timed out'); + }); + + it('keeps each operation with its variables, SQL and plan', () => { + expect(report).toContain( + '## [account-type-filters] GetFilteredAccounts is encrypted' + ); + expect(report).toContain('"limit": 26'); + expect(report).toContain('```sql\nSELECT 1\n```'); + expect(report).toContain( + '```text\nLimit (cost=0.28..12.37)\n -> Seq Scan on block\n```' + ); + }); + + it('reports skipped and failed operations', () => { + expect(report).toContain('SKIPPED: No sample id'); + expect(report).toContain('ERROR: explain failed (400)'); + }); +}); + +describe('selectBenchmarkEntries', () => { + it('keeps only the requested group', () => { + const entries = selectBenchmarkEntries('explorer', 'account-type-filters'); + + expect(entries.length).toBeGreaterThan(0); + expect(entries.every((e) => e.group === 'account-type-filters')).toBe(true); + }); + + it('fails for a group with no entries', () => { + expect(() => selectBenchmarkEntries('explorer', 'no-such-group')).toThrow( + 'No explorer benchmark entries in group "no-such-group"' + ); + }); +}); diff --git a/src/lib/graphql-benchmark/explain.ts b/src/lib/graphql-benchmark/explain.ts new file mode 100644 index 0000000..9a4cee1 --- /dev/null +++ b/src/lib/graphql-benchmark/explain.ts @@ -0,0 +1,223 @@ +import { type DocumentNode, print } from 'graphql'; + +import { + BENCHMARK_QUERY_TIMEOUT_MS, + createBenchmarkQuerySignal +} from './query-signal'; +import { + createBenchmarkApolloClient, + loadBenchmarkContext, + selectBenchmarkEntries +} from './run'; +import type { GraphqlBenchmarkContext, GraphqlBenchmarkSuite } from './types'; + +/** One root field of the operation, as returned by Hasura's explain API. */ +export type HasuraExplainPlan = { + field: string; + sql: string; + plan: string[]; +}; + +export type GraphqlExplainRow = { + name: string; + group?: string; + variables?: Record; + skipped?: boolean; + skipReason?: string; + plans?: HasuraExplainPlan[]; + errorMessage?: string; +}; + +function isExplainPlanList(body: unknown): body is HasuraExplainPlan[] { + return ( + Array.isArray(body) && + body.every( + (entry) => + typeof entry?.field === 'string' && + typeof entry?.sql === 'string' && + Array.isArray(entry?.plan) + ) + ); +} + +/** + * Generated SQL and Postgres `EXPLAIN` (estimates, not ANALYZE) for one operation. + * Hasura only serves this to the admin role. + */ +export async function explainGraphqlOperation(options: { + endpoint: string; + adminSecret: string; + document: DocumentNode; + variables: Record; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}): Promise { + const { endpoint, adminSecret, document, variables, signal } = options; + const fetchImpl = options.fetchImpl ?? fetch; + if (!adminSecret) { + throw new Error('Hasura explain needs an admin secret'); + } + + const response = await fetchImpl(`${endpoint.replace(/\/+$/, '')}/explain`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hasura-admin-secret': adminSecret + }, + body: JSON.stringify({ query: { query: print(document), variables } }), + signal + }); + const text = await response.text(); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new Error(`explain returned non-JSON (${response.status}): ${text}`); + } + + if (!response.ok) { + const message = + (body as { error?: unknown } | null)?.error ?? JSON.stringify(body); + throw new Error(`explain failed (${response.status}): ${String(message)}`); + } + if (!isExplainPlanList(body)) { + throw new Error(`explain returned an unexpected body: ${text}`); + } + return body; +} + +function jsonBlock(value: unknown): string { + return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; +} + +/** Markdown report of an explain run, one section per operation. */ +export function formatExplainReport(report: { + endpoint: string; + suite: GraphqlBenchmarkSuite; + group?: string; + generatedAt: Date; + bootstrapContext: GraphqlBenchmarkContext; + bootstrapRequestFailures: string[]; + results: GraphqlExplainRow[]; +}): string { + const lines = [ + '# GraphQL explain report', + '', + `- Endpoint: ${report.endpoint}`, + `- Suite: ${report.suite}`, + `- Group: ${report.group ?? 'all'}`, + `- Generated: ${report.generatedAt.toISOString()}`, + '', + '## Bootstrap context', + '', + jsonBlock(report.bootstrapContext) + ]; + if (report.bootstrapRequestFailures.length > 0) { + lines.push('', 'Bootstrap request failures:', ''); + for (const failure of report.bootstrapRequestFailures) { + lines.push(`- ${failure}`); + } + } + + for (const row of report.results) { + const label = row.group ? `[${row.group}] ` : ''; + lines.push('', `## ${label}${row.name}`, ''); + if (row.skipped) { + lines.push(`SKIPPED: ${row.skipReason ?? ''}`); + continue; + } + if (row.variables) { + lines.push('Variables:', '', jsonBlock(row.variables), ''); + } + if (row.errorMessage) { + lines.push(`ERROR: ${row.errorMessage}`); + continue; + } + for (const plan of row.plans ?? []) { + lines.push( + `### ${plan.field}`, + '', + `\`\`\`sql\n${plan.sql}\n\`\`\``, + '', + `\`\`\`text\n${plan.plan.join('\n')}\n\`\`\``, + '' + ); + } + } + return `${lines.join('\n').trimEnd()}\n`; +} + +export async function explainGraphqlBenchmarks(options: { + endpoint: string; + adminSecret: string; + suite?: GraphqlBenchmarkSuite; + group?: string; + timeoutMs?: number; + signal?: AbortSignal; +}): Promise<{ + bootstrapContext: GraphqlBenchmarkContext; + bootstrapRequestFailures: string[]; + results: GraphqlExplainRow[]; +}> { + const { + endpoint, + adminSecret, + suite = 'explorer', + group, + timeoutMs = BENCHMARK_QUERY_TIMEOUT_MS, + signal + } = options; + if (!adminSecret) { + throw new Error('Hasura explain needs an admin secret'); + } + const entries = selectBenchmarkEntries(suite, group); + const client = createBenchmarkApolloClient(endpoint); + const { context, requestFailures } = await loadBenchmarkContext( + client, + suite, + { timeoutMs, signal } + ); + + const results: GraphqlExplainRow[] = []; + /* eslint-disable no-await-in-loop -- one explain at a time keeps plans comparable */ + for (const entry of entries) { + const variables = entry.getVariables(context); + if (variables === null) { + results.push({ + name: entry.name, + group: entry.group, + skipped: true, + skipReason: 'No sample id from bootstrap for this operation' + }); + continue; + } + + const timedOut = createBenchmarkQuerySignal(timeoutMs, signal); + try { + const plans = await explainGraphqlOperation({ + endpoint, + adminSecret, + document: entry.document, + variables, + signal: timedOut.signal + }); + results.push({ name: entry.name, group: entry.group, variables, plans }); + } catch (e) { + results.push({ + name: entry.name, + group: entry.group, + variables, + errorMessage: e instanceof Error ? e.message : String(e) + }); + } finally { + timedOut.cleanup(); + } + } + /* eslint-enable no-await-in-loop */ + + return { + bootstrapContext: context, + bootstrapRequestFailures: requestFailures, + results + }; +} diff --git a/src/lib/graphql-benchmark/index.ts b/src/lib/graphql-benchmark/index.ts index 09d1a15..b740cb4 100644 --- a/src/lib/graphql-benchmark/index.ts +++ b/src/lib/graphql-benchmark/index.ts @@ -1,8 +1,18 @@ export { loadGraphqlBenchmarkContext } from './bootstrap'; +export { + explainGraphqlBenchmarks, + explainGraphqlOperation, + type GraphqlExplainRow, + type HasuraExplainPlan +} from './explain'; export { loadMobileBenchmarkContext } from './mobile-bootstrap'; -export { graphqlBenchmarkRegistry } from './registry'; export { mobileGraphqlBenchmarkRegistry } from './mobile-registry'; -export { createBenchmarkApolloClient, runGraphqlBenchmarks } from './run'; +export { graphqlBenchmarkRegistry } from './registry'; +export { + createBenchmarkApolloClient, + runGraphqlBenchmarks, + selectBenchmarkEntries +} from './run'; export type { GraphqlBenchmarkContext, GraphqlBenchmarkRegistryEntry, diff --git a/src/lib/graphql-benchmark/registry.ts b/src/lib/graphql-benchmark/registry.ts index fe5688c..091c227 100644 --- a/src/lib/graphql-benchmark/registry.ts +++ b/src/lib/graphql-benchmark/registry.ts @@ -3,9 +3,6 @@ import { endOfToday } from 'date-fns/endOfToday'; import { startOfToday } from 'date-fns/startOfToday'; import { - GetAccountByIdDocument, - GetAccountsDocument, - GetAccountsStatsDocument, GetBlockByIdDocument, GetBlocksDocument, GetCancelledReversibleTransactionByTxIdDocument, @@ -31,12 +28,25 @@ import { GetWormholeExtrinsicByIdDocument, GetWormholeExtrinsicsDocument } from '@/__generated__/graphql'; +import { + GET_ACCOUNT_BY_ID, + GET_ACCOUNTS, + GET_ACCOUNTS_STATS +} from '@/api/accounts'; import { QUERY_DEFAULT_LIMIT } from '@/constants/query-default-limit'; import { SEARCH_PREVIEW_RESULTS_LIMIT } from '@/constants/search-preview-results-limit'; import { getAccountStatsUtcDateRange } from '@/utils/get-account-stats-utc-date-range'; +import { accountTypeFilterBenchmarkRegistry } from './account-type-filter-registry'; import type { GraphqlBenchmarkRegistryEntry } from './types'; +/** Round trip with no database work; subtract it from other rows to estimate server time. */ +const NetworkFloorDocument = gql` + query NetworkFloor { + __typename + } +`; + const SearchHexDocument = gql` query SearchHex($keyword: String, $limit: Int) { transactions: unified_transaction( @@ -81,9 +91,14 @@ function statsDay() { } export const graphqlBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = [ + { + name: 'NetworkFloor', + document: NetworkFloorDocument, + getVariables: () => ({}) + }, { name: 'GetAccounts', - document: GetAccountsDocument, + document: GET_ACCOUNTS, getVariables: () => ({ orderBy: { id: 'desc' }, limit: QUERY_DEFAULT_LIMIT, @@ -92,12 +107,12 @@ export const graphqlBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = [ }, { name: 'GetAccountById', - document: GetAccountByIdDocument, + document: GET_ACCOUNT_BY_ID, getVariables: (ctx) => (ctx.accountId ? { id: ctx.accountId } : null) }, { name: 'GetAccountsStats', - document: GetAccountsStatsDocument, + document: GET_ACCOUNTS_STATS, getVariables: () => accountStatsDates() }, { @@ -290,5 +305,6 @@ export const graphqlBenchmarkRegistry: GraphqlBenchmarkRegistryEntry[] = [ document: GetWormholeExtrinsicByIdDocument, getVariables: (ctx) => ctx.wormholeExtrinsicId ? { id: ctx.wormholeExtrinsicId } : null - } + }, + ...accountTypeFilterBenchmarkRegistry ]; diff --git a/src/lib/graphql-benchmark/run.ts b/src/lib/graphql-benchmark/run.ts index cec82a7..5ea09e8 100644 --- a/src/lib/graphql-benchmark/run.ts +++ b/src/lib/graphql-benchmark/run.ts @@ -33,6 +33,37 @@ export function createBenchmarkApolloClient(uri: string) { }); } +/** Registry for the suite, narrowed to one group when given. */ +export function selectBenchmarkEntries( + suite: GraphqlBenchmarkSuite, + group?: string +): GraphqlBenchmarkRegistryEntry[] { + const registry = + suite === 'mobile' + ? mobileGraphqlBenchmarkRegistry + : graphqlBenchmarkRegistry; + if (group === undefined) return registry; + const entries = registry.filter((entry) => entry.group === group); + if (entries.length === 0) { + throw new Error(`No ${suite} benchmark entries in group "${group}"`); + } + return entries; +} + +export async function loadBenchmarkContext( + client: ApolloClient, + suite: GraphqlBenchmarkSuite, + options: { timeoutMs: number; signal?: AbortSignal } +): Promise<{ context: GraphqlBenchmarkContext; requestFailures: string[] }> { + if (suite === 'mobile') { + return loadMobileBenchmarkContext(client, options); + } + return { + context: await loadGraphqlBenchmarkContext(client), + requestFailures: [] + }; +} + function responseByteLength(data: unknown): number { try { return new TextEncoder().encode(JSON.stringify(data)).length; @@ -58,6 +89,7 @@ function median(values: number[]) { export async function runGraphqlBenchmarks(options: { endpoint: string; suite?: GraphqlBenchmarkSuite; + group?: string; samples?: number; warmup?: boolean; timeoutMs?: number; @@ -71,30 +103,19 @@ export async function runGraphqlBenchmarks(options: { const { endpoint, suite = 'explorer', + group, samples = 1, warmup = samples > 1, timeoutMs = BENCHMARK_QUERY_TIMEOUT_MS, signal, onProgress } = options; + const registry = selectBenchmarkEntries(suite, group); const client = createBenchmarkApolloClient(endpoint); - const registry: GraphqlBenchmarkRegistryEntry[] = - suite === 'mobile' - ? mobileGraphqlBenchmarkRegistry - : graphqlBenchmarkRegistry; - - let bootstrapContext: GraphqlBenchmarkContext; - let bootstrapRequestFailures: string[] = []; - if (suite === 'mobile') { - const loaded = await loadMobileBenchmarkContext(client, { - timeoutMs, - signal - }); - bootstrapContext = loaded.context; - bootstrapRequestFailures = loaded.requestFailures; - } else { - bootstrapContext = await loadGraphqlBenchmarkContext(client); - } + const { + context: bootstrapContext, + requestFailures: bootstrapRequestFailures + } = await loadBenchmarkContext(client, suite, { timeoutMs, signal }); const results: GraphqlBenchmarkRow[] = []; /* eslint-disable no-await-in-loop -- benchmarks run strictly sequentially */ diff --git a/src/lib/graphql-benchmark/types.ts b/src/lib/graphql-benchmark/types.ts index 0a424e6..8c4fce1 100644 --- a/src/lib/graphql-benchmark/types.ts +++ b/src/lib/graphql-benchmark/types.ts @@ -16,6 +16,8 @@ export type GraphqlBenchmarkContext = { /** Account with the most immediate transfers (wallet-history worst case). */ busyAccountId?: string; busyImmediateTransfers?: number; + /** Account with the fewest (but some) immediate transfers (account-history best case). */ + quietAccountId?: string; /** Busiest accounts, longest history first, for multi-account fan-out. */ walletAccountIds?: string[]; /** Account with the most mined blocks (receive/all feed worst case). */ diff --git a/src/schemas/account.ts b/src/schemas/account.ts index ba69773..b280e43 100644 --- a/src/schemas/account.ts +++ b/src/schemas/account.ts @@ -5,13 +5,23 @@ export type Account = Pick< 'id' | 'free' | 'frozen' | 'reserved' | 'is_deposit_only' >; +/** At most one mined block, fetched only to tell whether the account has ever mined. */ +export interface AccountMinedBlocksPeek { + minedBlocks: { height: number }[]; +} + /** Listing row: flags are denormalized onto `account` by the indexer (no nested event lookups). */ -export interface AccountListItem extends Account { +export interface AccountListItem extends Account, AccountMinedBlocksPeek { is_high_security: boolean; is_guardian: boolean; is_multisig: boolean; } +export interface AccountListPageResponse { + accounts: AccountListItem[]; + hasNextPage: boolean; +} + export interface AccountStats { total_cancelled_transfers: number; total_executed_transfers: number; @@ -22,7 +32,7 @@ export interface AccountStats { } export interface AccountResponse { - account: Account; + account: (Account & AccountMinedBlocksPeek) | null; accountStats: AccountStats; multisig?: { id: string } | null; beneficiaries: { diff --git a/src/schemas/unified-list-transaction.ts b/src/schemas/unified-list-transaction.ts index dac13c9..12da0e8 100644 --- a/src/schemas/unified-list-transaction.ts +++ b/src/schemas/unified-list-transaction.ts @@ -37,6 +37,11 @@ export interface UnifiedListTransactionListResponse { }; } +export interface UnifiedListTransactionPageResponse { + transactions: UnifiedListTransaction[]; + hasNextPage: boolean; +} + export interface RecentUnifiedListTransactionsResponse { transactions: UnifiedListTransaction[]; } diff --git a/src/types/react-table.d.ts b/src/types/react-table.d.ts index a0fb74b..6753b54 100644 --- a/src/types/react-table.d.ts +++ b/src/types/react-table.d.ts @@ -6,4 +6,9 @@ declare module '@tanstack/react-table' { className: string; }; } + + interface TableMeta { + /** Filtered lists only know whether a next page exists, not the last page. */ + totalCountUnknown?: boolean; + } } diff --git a/src/utils/account-type-filter.test.ts b/src/utils/account-type-filter.test.ts new file mode 100644 index 0000000..dc800db --- /dev/null +++ b/src/utils/account-type-filter.test.ts @@ -0,0 +1,222 @@ +import { + accountTypeFilteredTransactionsWhere, + accountTypeRuleWhere, + accountTypeWhere, + parseAccountTypeFilterRule, + partyAccountTypeRulesWhere, + serializeAccountTypeFilterRule, + toggleRuleType +} from './account-type-filter'; +import { EXCLUDE_REWARD_TRANSFERS } from './unified-transaction-filters'; + +const STANDARD = { + is_high_security: { _eq: false }, + is_guardian: { _eq: false }, + is_multisig: { _eq: false } +}; + +describe('accountTypeWhere', () => { + it('matches only unflagged non-deposit-only accounts as transparent', () => { + expect(accountTypeWhere('transparent')).toEqual({ + ...STANDARD, + is_deposit_only: { _eq: false } + }); + }); + + it('matches unflagged deposit-only accounts without mined blocks as potential encrypted', () => { + expect(accountTypeWhere('potential_encrypted')).toEqual({ + ...STANDARD, + is_deposit_only: { _eq: true }, + _not: { minedBlocks: {} } + }); + }); + + it('matches unflagged deposit-only accounts with mined blocks as encrypted', () => { + expect(accountTypeWhere('encrypted')).toEqual({ + ...STANDARD, + is_deposit_only: { _eq: true }, + minedBlocks: {} + }); + }); + + it.each([ + ['high_security', { is_high_security: { _eq: true } }], + ['guardian', { is_guardian: { _eq: true } }], + ['multisig', { is_multisig: { _eq: true } }] + ] as const)('matches %s by its flag alone', (type, expected) => { + expect(accountTypeWhere(type)).toEqual(expected); + }); +}); + +describe('accountTypeRuleWhere', () => { + it('returns null when the rule has no types selected', () => { + expect(accountTypeRuleWhere({ operator: 'is', types: [] })).toBeNull(); + expect(accountTypeRuleWhere({ operator: 'is_not', types: [] })).toBeNull(); + }); + + it('keeps only accounts matching any selected type for "is"', () => { + expect( + accountTypeRuleWhere({ operator: 'is', types: ['guardian', 'multisig'] }) + ).toEqual({ + _or: [accountTypeWhere('guardian'), accountTypeWhere('multisig')] + }); + }); + + it('drops accounts matching any selected type for "is not"', () => { + expect( + accountTypeRuleWhere({ operator: 'is_not', types: ['transparent'] }) + ).toEqual({ _not: { _or: [accountTypeWhere('transparent')] } }); + }); +}); + +describe('partyAccountTypeRulesWhere', () => { + it('returns null when there are no rules', () => { + expect(partyAccountTypeRulesWhere({})).toBeNull(); + }); + + it('returns null when every rule is empty', () => { + expect( + partyAccountTypeRulesWhere({ + from: { operator: 'is', types: [] }, + to: { operator: 'is_not', types: [] } + }) + ).toBeNull(); + }); + + it('keeps rows whose sender matches for a "from is" rule', () => { + expect( + partyAccountTypeRulesWhere({ + from: { operator: 'is', types: ['encrypted', 'guardian'] } + }) + ).toEqual({ + from: { + _or: [accountTypeWhere('encrypted'), accountTypeWhere('guardian')] + } + }); + }); + + it('drops rows whose receiver matches for a "to is not" rule, keeping rows without a receiver', () => { + expect( + partyAccountTypeRulesWhere({ + to: { operator: 'is_not', types: ['multisig'] } + }) + ).toEqual({ _not: { to: { _or: [accountTypeWhere('multisig')] } } }); + }); + + it('requires both sides when sender and receiver rules are set', () => { + expect( + partyAccountTypeRulesWhere({ + from: { operator: 'is', types: ['transparent'] }, + to: { operator: 'is_not', types: ['guardian'] } + }) + ).toEqual({ + _and: [ + { from: { _or: [accountTypeWhere('transparent')] } }, + { _not: { to: { _or: [accountTypeWhere('guardian')] } } } + ] + }); + }); + + it('ignores an empty rule next to an active one', () => { + expect( + partyAccountTypeRulesWhere({ + from: { operator: 'is', types: [] }, + to: { operator: 'is', types: ['guardian'] } + }) + ).toEqual({ to: { _or: [accountTypeWhere('guardian')] } }); + }); +}); + +describe('accountTypeFilteredTransactionsWhere', () => { + const fromGuardian = { operator: 'is', types: ['guardian'] } as const; + const partyWhere = { from: { _or: [accountTypeWhere('guardian')] } }; + + it('returns null when no rule narrows results', () => { + expect( + accountTypeFilteredTransactionsWhere(undefined, { + from: { operator: 'is', types: [] } + }) + ).toBeNull(); + }); + + it('excludes reward transfers around the party rules', () => { + expect( + accountTypeFilteredTransactionsWhere(undefined, { from: fromGuardian }) + ).toEqual({ _and: [EXCLUDE_REWARD_TRANSFERS, partyWhere] }); + }); + + it('requires the table filter and the party rules together', () => { + const baseWhere = { block: { height: { _eq: 7 } } }; + + expect( + accountTypeFilteredTransactionsWhere(baseWhere, { from: fromGuardian }) + ).toEqual({ + _and: [EXCLUDE_REWARD_TRANSFERS, { _and: [baseWhere, partyWhere] }] + }); + }); +}); + +describe('parseAccountTypeFilterRule', () => { + it('parses an operator with its selected types', () => { + expect(parseAccountTypeFilterRule('is:guardian,multisig')).toEqual({ + operator: 'is', + types: ['guardian', 'multisig'] + }); + }); + + it('parses a rule with no types selected yet', () => { + expect(parseAccountTypeFilterRule('is_not:')).toEqual({ + operator: 'is_not', + types: [] + }); + }); + + it('returns types in canonical order without duplicates', () => { + expect( + parseAccountTypeFilterRule('is:multisig,transparent,multisig') + ).toEqual({ operator: 'is', types: ['transparent', 'multisig'] }); + }); + + it.each(['', 'is', 'contains:guardian', 'is:guardian,unknown', 'is:,'])( + 'rejects malformed value %j', + (value) => { + expect(parseAccountTypeFilterRule(value)).toBeNull(); + } + ); +}); + +describe('serializeAccountTypeFilterRule', () => { + it('round-trips through the parser', () => { + const rule = { + operator: 'is_not', + types: ['potential_encrypted', 'high_security'] + } as const; + + expect( + parseAccountTypeFilterRule(serializeAccountTypeFilterRule(rule)) + ).toEqual(rule); + }); + + it('keeps a rule with no types selected', () => { + expect(serializeAccountTypeFilterRule({ operator: 'is', types: [] })).toBe( + 'is:' + ); + }); +}); + +describe('toggleRuleType', () => { + it('adds an unselected type in canonical order', () => { + expect( + toggleRuleType({ operator: 'is', types: ['multisig'] }, 'transparent') + ).toEqual({ operator: 'is', types: ['transparent', 'multisig'] }); + }); + + it('removes a selected type', () => { + expect( + toggleRuleType( + { operator: 'is_not', types: ['guardian', 'multisig'] }, + 'guardian' + ) + ).toEqual({ operator: 'is_not', types: ['multisig'] }); + }); +}); diff --git a/src/utils/account-type-filter.ts b/src/utils/account-type-filter.ts new file mode 100644 index 0000000..10e4669 --- /dev/null +++ b/src/utils/account-type-filter.ts @@ -0,0 +1,150 @@ +import type { + Account_Bool_Exp, + Unified_Transaction_Bool_Exp +} from '@/__generated__/graphql'; +import { + ACCOUNT_TYPES, + type AccountType, + type AccountTypeFilterRule, + FILTER_OPERATORS, + type FilterOperator +} from '@/constants/account-types'; +import { withExcludedRewardTransfers } from '@/utils/unified-transaction-filters'; + +const STANDARD_ACCOUNT = { + is_high_security: { _eq: false }, + is_guardian: { _eq: false }, + is_multisig: { _eq: false } +} as const satisfies Account_Bool_Exp; + +/** + * Flags take priority: flagged accounts only match their flag types, and the + * three address types only match unflagged accounts. + * Keep in sync with `classifyAccountAddress`. + */ +export function accountTypeWhere(type: AccountType): Account_Bool_Exp { + switch (type) { + case 'transparent': + return { ...STANDARD_ACCOUNT, is_deposit_only: { _eq: false } }; + case 'potential_encrypted': + return { + ...STANDARD_ACCOUNT, + is_deposit_only: { _eq: true }, + _not: { minedBlocks: {} } + }; + case 'encrypted': + return { + ...STANDARD_ACCOUNT, + is_deposit_only: { _eq: true }, + minedBlocks: {} + }; + case 'high_security': + return { is_high_security: { _eq: true } }; + case 'guardian': + return { is_guardian: { _eq: true } }; + case 'multisig': + return { is_multisig: { _eq: true } }; + default: { + const unhandled: never = type; + throw new Error(`Unhandled account type: ${String(unhandled)}`); + } + } +} + +function anyAccountTypeWhere(types: readonly AccountType[]): Account_Bool_Exp { + return { _or: types.map(accountTypeWhere) }; +} + +/** Account filter for one rule; null while the rule has no types selected. */ +export function accountTypeRuleWhere( + rule: AccountTypeFilterRule +): Account_Bool_Exp | null { + if (rule.types.length === 0) return null; + const anyType = anyAccountTypeWhere(rule.types); + return rule.operator === 'is' ? anyType : { _not: anyType }; +} + +function partyRuleWhere( + party: 'from' | 'to', + rule: AccountTypeFilterRule | undefined +): Unified_Transaction_Bool_Exp | null { + if (!rule || rule.types.length === 0) return null; + const partyMatches = { [party]: anyAccountTypeWhere(rule.types) }; + // `_not` around the relationship (rather than inside it) keeps rows without that party, e.g. mints. + return rule.operator === 'is' ? partyMatches : { _not: partyMatches }; +} + +/** Transaction filter for sender / receiver account-type rules; null when no rule narrows results. */ +export function partyAccountTypeRulesWhere(rules: { + from?: AccountTypeFilterRule; + to?: AccountTypeFilterRule; +}): Unified_Transaction_Bool_Exp | null { + const clauses = [ + partyRuleWhere('from', rules.from), + partyRuleWhere('to', rules.to) + ].filter((clause) => clause !== null); + const [only, ...rest] = clauses; + if (!only) return null; + return rest.length === 0 ? only : { _and: clauses }; +} + +/** + * Full `where` for a transaction table with sender / receiver rules applied; + * null when no rule narrows results, so the table keeps its counted query. + */ +export function accountTypeFilteredTransactionsWhere( + baseWhere: Unified_Transaction_Bool_Exp | undefined, + rules: { from?: AccountTypeFilterRule; to?: AccountTypeFilterRule } +): Unified_Transaction_Bool_Exp | null { + const partyWhere = partyAccountTypeRulesWhere(rules); + if (!partyWhere) return null; + return withExcludedRewardTransfers( + baseWhere ? { _and: [baseWhere, partyWhere] } : partyWhere + ); +} + +function isFilterOperator(value: string): value is FilterOperator { + return (FILTER_OPERATORS as readonly string[]).includes(value); +} + +function isAccountType(value: string): value is AccountType { + return (ACCOUNT_TYPES as readonly string[]).includes(value); +} + +function canonicalTypes(types: readonly AccountType[]): AccountType[] { + return ACCOUNT_TYPES.filter((type) => types.includes(type)); +} + +/** Parses the `operator:type,type` URL form; null for anything malformed. */ +export function parseAccountTypeFilterRule( + value: string +): AccountTypeFilterRule | null { + const separator = value.indexOf(':'); + if (separator === -1) return null; + + const operator = value.slice(0, separator); + if (!isFilterOperator(operator)) return null; + + const list = value.slice(separator + 1); + if (list === '') return { operator, types: [] }; + + const parts = list.split(','); + if (!parts.every(isAccountType)) return null; + return { operator, types: canonicalTypes(parts) }; +} + +export function serializeAccountTypeFilterRule( + rule: AccountTypeFilterRule +): string { + return `${rule.operator}:${rule.types.join(',')}`; +} + +export function toggleRuleType( + rule: AccountTypeFilterRule, + type: AccountType +): AccountTypeFilterRule { + const types = rule.types.includes(type) + ? rule.types.filter((t) => t !== type) + : canonicalTypes([...rule.types, type]); + return { ...rule, types }; +} diff --git a/src/utils/classify-account-address.test.ts b/src/utils/classify-account-address.test.ts index 2a710b4..0d3899d 100644 --- a/src/utils/classify-account-address.test.ts +++ b/src/utils/classify-account-address.test.ts @@ -7,22 +7,47 @@ const standard = { }; describe('classifyAccountAddress', () => { - it('classifies a standard deposit-only account as a potential encrypted address', () => { - expect(classifyAccountAddress({ ...standard, isDepositOnly: true })).toBe( - 'Potential Encrypted Address' - ); + it('classifies a standard deposit-only account that has mined as an encrypted address', () => { + expect( + classifyAccountAddress({ + ...standard, + isDepositOnly: true, + hasMinedBlocks: true + }) + ).toBe('Encrypted Address'); }); - it('classifies a standard account that is not deposit-only as a transparent address', () => { - expect(classifyAccountAddress({ ...standard, isDepositOnly: false })).toBe( - 'Transparent Address' - ); + it('classifies a standard deposit-only account that never mined as a potential encrypted address', () => { + expect( + classifyAccountAddress({ + ...standard, + isDepositOnly: true, + hasMinedBlocks: false + }) + ).toBe('Potential Encrypted Address'); }); + it.each([true, false])( + 'classifies a standard account that is not deposit-only as a transparent address (hasMinedBlocks: %p)', + (hasMinedBlocks) => { + expect( + classifyAccountAddress({ + ...standard, + isDepositOnly: false, + hasMinedBlocks + }) + ).toBe('Transparent Address'); + } + ); + it('classifies a missing account row as a transparent address', () => { - expect(classifyAccountAddress({ ...standard, isDepositOnly: null })).toBe( - 'Transparent Address' - ); + expect( + classifyAccountAddress({ + ...standard, + isDepositOnly: null, + hasMinedBlocks: false + }) + ).toBe('Transparent Address'); }); it.each([ @@ -30,11 +55,15 @@ describe('classifyAccountAddress', () => { { isHighSecurity: false, isGuardian: true, isMultisig: false }, { isHighSecurity: false, isGuardian: false, isMultisig: true } ])( - 'classifies a flagged account as a transparent address even when it is deposit-only', + 'classifies a flagged account as a transparent address even when it is deposit-only and has mined', (flags) => { - expect(classifyAccountAddress({ ...flags, isDepositOnly: true })).toBe( - 'Transparent Address' - ); + expect( + classifyAccountAddress({ + ...flags, + isDepositOnly: true, + hasMinedBlocks: true + }) + ).toBe('Transparent Address'); } ); }); diff --git a/src/utils/classify-account-address.ts b/src/utils/classify-account-address.ts index a2f4597..05cebdf 100644 --- a/src/utils/classify-account-address.ts +++ b/src/utils/classify-account-address.ts @@ -1,5 +1,6 @@ export const TRANSPARENT_ADDRESS_LABEL = 'Transparent Address'; export const POTENTIAL_ENCRYPTED_ADDRESS_LABEL = 'Potential Encrypted Address'; +export const ENCRYPTED_ADDRESS_LABEL = 'Encrypted Address'; export interface AccountAddressClassification { isHighSecurity: boolean; @@ -7,18 +8,23 @@ export interface AccountAddressClassification { isMultisig: boolean; /** Null when the indexer has no account row yet. */ isDepositOnly: boolean | null; + hasMinedBlocks: boolean; } +/** Keep in sync with `accountTypeWhere` in `account-type-filter.ts`. */ export function classifyAccountAddress({ isHighSecurity, isGuardian, isMultisig, - isDepositOnly + isDepositOnly, + hasMinedBlocks }: AccountAddressClassification): string { const isStandard = !isHighSecurity && !isGuardian && !isMultisig; if (isStandard && isDepositOnly === true) { - return POTENTIAL_ENCRYPTED_ADDRESS_LABEL; + return hasMinedBlocks + ? ENCRYPTED_ADDRESS_LABEL + : POTENTIAL_ENCRYPTED_ADDRESS_LABEL; } return TRANSPARENT_ADDRESS_LABEL; diff --git a/src/utils/uncounted-pagination.test.ts b/src/utils/uncounted-pagination.test.ts new file mode 100644 index 0000000..b80c607 --- /dev/null +++ b/src/utils/uncounted-pagination.test.ts @@ -0,0 +1,49 @@ +import { takePeekedPage, uncountedRowCount } from './uncounted-pagination'; + +describe('takePeekedPage', () => { + it('reports a next page and drops the peek row when the query returned limit + 1 rows', () => { + expect(takePeekedPage([1, 2, 3, 4], 3)).toEqual({ + rows: [1, 2, 3], + hasNextPage: true + }); + }); + + it('reports no next page when the query returned exactly limit rows', () => { + expect(takePeekedPage([1, 2, 3], 3)).toEqual({ + rows: [1, 2, 3], + hasNextPage: false + }); + }); + + it('reports no next page for a short last page', () => { + expect(takePeekedPage([1], 3)).toEqual({ rows: [1], hasNextPage: false }); + }); + + it('rejects a non-positive page size', () => { + expect(() => takePeekedPage([1], 0)).toThrow(RangeError); + }); +}); + +describe('uncountedRowCount', () => { + it('extends one row past the current page when a next page exists', () => { + expect( + uncountedRowCount({ + pageIndex: 2, + limit: 25, + pageRowCount: 25, + hasNextPage: true + }) + ).toBe(76); + }); + + it('ends at the last row of the current page when there is no next page', () => { + expect( + uncountedRowCount({ + pageIndex: 2, + limit: 25, + pageRowCount: 7, + hasNextPage: false + }) + ).toBe(57); + }); +}); diff --git a/src/utils/uncounted-pagination.ts b/src/utils/uncounted-pagination.ts new file mode 100644 index 0000000..7dd094b --- /dev/null +++ b/src/utils/uncounted-pagination.ts @@ -0,0 +1,33 @@ +/** + * Filtered lists skip the total count (a filtered aggregate is too slow), so + * they request one row past the page size to learn whether a next page exists. + */ +export const peekedLimit = (limit: number) => limit + 1; + +export function takePeekedPage( + rows: readonly T[], + limit: number +): { rows: T[]; hasNextPage: boolean } { + if (limit <= 0) { + throw new RangeError(`page size must be positive, got ${limit}`); + } + return { + rows: rows.slice(0, limit), + hasNextPage: rows.length > limit + }; +} + +/** Row count to hand the table so its pager allows exactly one more page when one exists. */ +export function uncountedRowCount({ + pageIndex, + limit, + pageRowCount, + hasNextPage +}: { + pageIndex: number; + limit: number; + pageRowCount: number; + hasNextPage: boolean; +}): number { + return pageIndex * limit + pageRowCount + (hasNextPage ? 1 : 0); +} diff --git a/src/utils/unified-transaction-filters.test.ts b/src/utils/unified-transaction-filters.test.ts index c229d8c..25a7e36 100644 --- a/src/utils/unified-transaction-filters.test.ts +++ b/src/utils/unified-transaction-filters.test.ts @@ -1,4 +1,5 @@ import { + accountPartyWhere, EXCLUDE_REWARD_TRANSFERS, extractAccountPartyId, isUnfilteredExcludeRewards, @@ -6,6 +7,22 @@ import { withExcludedRewardTransfers } from './unified-transaction-filters'; +describe('accountPartyWhere', () => { + it('matches rows where the account is sender or receiver', () => { + expect(accountPartyWhere('qz1')).toEqual({ + _or: [{ from_id: { _eq: 'qz1' } }, { to_id: { _eq: 'qz1' } }] + }); + }); + + it('is recognised as an account party list for the precomputed total', () => { + expect( + extractAccountPartyId( + withExcludedRewardTransfers(accountPartyWhere('qz1')) + ) + ).toBe('qz1'); + }); +}); + describe('last24HourTransferredWhere', () => { it('includes signed immediate, wormhole, and executed reversible amounts only', () => { const where = last24HourTransferredWhere( @@ -47,10 +64,7 @@ describe('isUnfilteredExcludeRewards', () => { expect( isUnfilteredExcludeRewards( withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: 'acc1' } } }, - { to: { id: { _eq: 'acc1' } } } - ] + _or: [{ from_id: { _eq: 'acc1' } }, { to_id: { _eq: 'acc1' } }] }) ) ).toBe(false); @@ -70,10 +84,7 @@ describe('extractAccountPartyId', () => { expect( extractAccountPartyId( withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: accountId } } }, - { to: { id: { _eq: accountId } } } - ] + _or: [{ from_id: { _eq: accountId } }, { to_id: { _eq: accountId } }] }) ) ).toBe(accountId); @@ -82,10 +93,7 @@ describe('extractAccountPartyId', () => { it('extracts id from bare from/to same-id OR', () => { expect( extractAccountPartyId({ - _or: [ - { from: { id: { _eq: accountId } } }, - { to: { id: { _eq: accountId } } } - ] + _or: [{ from_id: { _eq: accountId } }, { to_id: { _eq: accountId } }] }) ).toBe(accountId); }); @@ -104,10 +112,7 @@ describe('extractAccountPartyId', () => { expect( extractAccountPartyId( withExcludedRewardTransfers({ - _or: [ - { from: { id: { _eq: accountId } } }, - { to: { id: { _eq: 'other' } } } - ] + _or: [{ from_id: { _eq: accountId } }, { to_id: { _eq: 'other' } }] }) ) ).toBeNull(); @@ -117,14 +122,14 @@ describe('extractAccountPartyId', () => { expect( extractAccountPartyId( withExcludedRewardTransfers({ - from: { id: { _eq: accountId } } + from_id: { _eq: accountId } }) ) ).toBeNull(); expect( extractAccountPartyId( withExcludedRewardTransfers({ - _or: [{ from: { id: { _eq: accountId } } }] + _or: [{ from_id: { _eq: accountId } }] }) ) ).toBeNull(); diff --git a/src/utils/unified-transaction-filters.ts b/src/utils/unified-transaction-filters.ts index 92c7986..f945e75 100644 --- a/src/utils/unified-transaction-filters.ts +++ b/src/utils/unified-transaction-filters.ts @@ -53,6 +53,19 @@ function isExcludeRewardsClause( return hasImmediate && hasNullHash; } +/** + * Rows where the account is sender or receiver; the shape {@link extractAccountPartyId} recognises. + * Filters the FK columns, not the `from` / `to` relationships: those compile to per-row EXISTS + * probes that walk the global timestamp index instead of the (from|to, timestamp) indexes. + */ +export function accountPartyWhere( + accountId: string +): Unified_Transaction_Bool_Exp { + return { + _or: [{ from_id: { _eq: accountId } }, { to_id: { _eq: accountId } }] + }; +} + function definedKeys(obj: object): string[] { return Object.keys(obj).filter( (k) => (obj as Record)[k] != null @@ -89,19 +102,13 @@ export function extractAccountPartyId( for (const clause of partyWhere._or) { if (!clause || definedKeys(clause).length !== 1) return null; - const fromEq = clause.from?.id?._eq; - const toEq = clause.to?.id?._eq; - if (typeof fromEq === 'string' && clause.from && !clause.to) { - if (definedKeys(clause.from).length !== 1) return null; - if (!clause.from.id || definedKeys(clause.from.id).length !== 1) { - return null; - } + const fromEq = clause.from_id?._eq; + const toEq = clause.to_id?._eq; + if (typeof fromEq === 'string' && clause.from_id) { + if (definedKeys(clause.from_id).length !== 1) return null; fromId = fromEq; - } else if (typeof toEq === 'string' && clause.to && !clause.from) { - if (definedKeys(clause.to).length !== 1) return null; - if (!clause.to.id || definedKeys(clause.to.id).length !== 1) { - return null; - } + } else if (typeof toEq === 'string' && clause.to_id) { + if (definedKeys(clause.to_id).length !== 1) return null; toId = toEq; } else { return null; From 5237f6d4c72f24771b99c8f5b4c2387eb6bd9ca4 Mon Sep 17 00:00:00 2001 From: Beast Date: Thu, 1 Oct 2026 20:46:04 +0800 Subject: [PATCH 2/2] chore: revert endpoints --- src/components/common/network-provider/network-provider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/common/network-provider/network-provider.tsx b/src/components/common/network-provider/network-provider.tsx index 8a464f9..ea42523 100644 --- a/src/components/common/network-provider/network-provider.tsx +++ b/src/components/common/network-provider/network-provider.tsx @@ -4,8 +4,8 @@ const ENABLE_LOCAL_NETWORK = import.meta.env.VITE_ENABLE_LOCAL_NETWORK === 'true'; const BASE_NETWORKS = { - mainnet: 'http://127.0.0.1:8080/https://sqm.quantus.com/v1/graphql', - planck: 'http://127.0.0.1:8080/https://sub2.quantus.com/v1/graphql' + mainnet: 'https://sqm.quantus.com/v1/graphql', + planck: 'https://sub2.quantus.com/v1/graphql' } as const; const LOCAL_NETWORK = {