From 9cafa0709cbd5e623908e9ce4f7396762e23b585 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 5 Mar 2026 12:48:34 -0700 Subject: [PATCH 1/4] docs: tighten harness process guidance and add app/indexer state audit --- CLAUDE.md | 19 +++-- docs/app-state-audit-2026-03-05.md | 115 +++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 docs/app-state-audit-2026-03-05.md diff --git a/CLAUDE.md b/CLAUDE.md index 5b5c327f6..469b0ca8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the Tangle dApp monorepo - a collection of decentralized applications serving as the frontend for the Tangle Network, a Substrate-based cryptocurrency network in the Polkadot ecosystem. Tangle is a layer 1 for on-demand services where developers can build and monetize decentralized services using Tangle Blueprints. +This is the Tangle dApp monorepo - a collection of decentralized applications for the Tangle Operator Layer for AI services, built on the TNT EVM protocol stack (`tnt-core`). The monorepo uses Nx for fast, extensible building with `apps/` containing interfaces and `libs/` containing shared code. @@ -45,9 +45,9 @@ yarn generate:release # Review version bumps and changelog ## Architecture & Key Concepts ### Applications (apps/) -- **tangle-dapp**: Main dApp for managing Tangle Network assets and MPC services -- **tangle-cloud**: Cloud interface for Tangle services -- **leaderboard**: Validator leaderboard application +- **tangle-dapp**: Main dApp for staking, delegation, rewards, migration claims, and wallet flows +- **tangle-cloud**: Operator/developer interface for blueprint and service lifecycle management +- **leaderboard**: Points and participation leaderboard ### Libraries (libs/) - **abstract-api-provider**: Base classes unifying API across providers @@ -64,7 +64,7 @@ yarn generate:release # Review version bumps and changelog ### Tech Stack - **Frontend**: Vite, TypeScript, React, TailwindCSS -- **Blockchain**: EVM + PolkadotJS (metadata-driven runtime types) +- **Blockchain**: EVM-first (`viem`/`wagmi`) with limited PolkadotJS usage for migration-claim interoperability - **Build System**: Nx monorepo - **Styling**: TailwindCSS with custom preset @@ -79,6 +79,13 @@ yarn generate:release # Review version bumps and changelog - Avoid “do you want me to…” phrasing when the expected next step is obvious from context. - For launch-flow-impacting changes, follow `docs/harness-engineering-spec.md` and complete `docs/harness-engineering-checklist.md` before requesting merge. +### Harness Release Process (Succinct) +- Scope launch-impacting work to explicit flow IDs in `docs/launch-readiness-board.csv`. +- Run harness suite: `yarn test:wallet-flows` and inspect `suite/report.json` + `suite/release-matrix.md`. +- Enforce gate: `yarn test:wallet-flows:gate` (or `:strict` when required). +- Critical flows (`FLOW-001,002,005,010,011,013,014,018,019`) must be `happy-path-pass` unless exception owner/ETA is documented. +- Include matrix summary and gate output in PR using the harness section in `.github/PULL_REQUEST_TEMPLATE.md`. + ### Wallet Flow Reliability (agent-browser-driver) - Treat wallet E2E as environment-first: do not trust flow results until local chain + indexer + dApp are confirmed on the same network. - Minimum readiness gate before running wallet flows: @@ -112,7 +119,7 @@ yarn generate:release # Review version bumps and changelog ### Important Notes - **Localize changes**: Keep changes isolated to relevant projects unless shared libraries are involved - **Package dependencies**: Don't assume packages exist - check imports or root `package.json` first -- **Number handling**: For values > u32 from chain, use `BN` or `bigint`. For u32 or smaller, use `.toNumber()` +- **Number handling**: Prefer `bigint`/`viem` primitives for chain values; avoid introducing new `BN` usage. - **Monorepo scope**: Avoid cross-project changes unless working with shared libs - **Storybook**: Considered legacy, avoid creating/modifying storybook files - **Testing**: No testing libraries currently used or planned diff --git a/docs/app-state-audit-2026-03-05.md b/docs/app-state-audit-2026-03-05.md new file mode 100644 index 000000000..d4e299d64 --- /dev/null +++ b/docs/app-state-audit-2026-03-05.md @@ -0,0 +1,115 @@ +# App State Audit (2026-03-05) + +## Scope + +- Apps reviewed: `tangle-dapp`, `tangle-cloud`, `leaderboard` +- Source-of-truth cross-check: `~/code/tnt-core/indexer/schema.graphql` +- Goals: + - current runtime/build/test state + - indexer connectivity status + - design/UX/system-level gaps + +## Executive Summary + +- `tangle-dapp`: healthy build/test state; indexer wiring uses shared Envio utilities with on-chain fallback. +- `tangle-cloud`: healthy build/test state; indexer wiring uses shared Envio utilities and tx refresh patterns. +- `leaderboard`: connected to `tnt-core` Envio schema and functional, but has correctness and scalability gaps that should be fixed before calling it production-grade. + +## Verification Evidence + +- `yarn nx test tangle-dapp` -> pass (`31` tests) +- `yarn nx test tangle-cloud` -> pass (`49` tests) +- `yarn nx test leaderboard` -> pass but no tests found +- `yarn nx build tangle-dapp` -> pass +- `yarn nx build tangle-cloud` -> pass +- `yarn nx build leaderboard` -> pass + +## Indexer Connectivity Validation + +### `leaderboard` is connected to `tnt-core` indexer entities + +- Leaderboard queries `PointsAccount` + `snapshots`: [leaderboardQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts#L90) +- Role filters query `Operator`, `Delegator`, `Blueprint`, `JobCall`: [leaderboardQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts#L276) +- These entities/fields exist in `tnt-core` schema: + - `PointsAccount` / `PointsSnapshot`: [schema.graphql](/home/drew/code/tnt-core/indexer/schema.graphql#L798) + - `Operator`, `Blueprint`, `Service`, `JobCall`: [schema.graphql](/home/drew/code/tnt-core/indexer/schema.graphql#L193) + - `Delegator`: [schema.graphql](/home/drew/code/tnt-core/indexer/schema.graphql#L518) + +### Caveat + +- `chain_metadata` query in leaderboard sync chip is Hasura/Envio metadata-table dependent and not part of the app-level GraphQL schema contract: + - [indexingProgressQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts#L13) + +## Findings (Ordered by Severity) + +### High + +1. Pagination count is incorrect in leaderboard +- `totalCount` is set to the current page filtered length, not global count: + - [leaderboardQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts#L202) +- Impact: pagination controls and UX can be incorrect on multi-page datasets. + +2. Role filtering query does unbounded full-table scans +- Queries all `Operator`, filtered `Delegator`, all `Blueprint`, all `JobCall` with no paging/aggregation: + - [leaderboardQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts#L276) +- Impact: slow queries and degraded UX as data grows. + +### Medium + +3. Indexing “target” is synthetic (`latest + 1`) +- [indexingProgressQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/indexingProgress/queries/indexingProgressQuery.ts#L79) +- Impact: “Synced” can be noisy/misleading. + +4. Leaderboard has zero automated tests +- No `*.test.*`/`*.spec.*` files under `apps/leaderboard/src`. +- Impact: regressions are likely to slip through. + +5. Team-account filter placeholder is not production-ready +- Placeholder zero address only: + - [leaderboardQuery.ts](/home/drew/code/dapp/apps/leaderboard/src/features/leaderboard/queries/leaderboardQuery.ts#L7) +- Impact: internal accounts may appear in rankings. + +### Low + +6. Product copy drift in leaderboard +- Mentions “nominating” in hero copy: + - [index.tsx](/home/drew/code/dapp/apps/leaderboard/src/pages/index.tsx#L17) +- Impact: terminology drift vs current EVM/operator-layer framing. + +7. Bundle size is high across apps +- Build outputs include large chunks (notably `tangle-dapp` and `tangle-cloud`). +- Impact: page-load/perf cost on slower clients. + +## Design/System Audit Snapshot + +- Shared visual system is consistent across apps (common UI provider/layout patterns). +- `leaderboard` is structurally minimal (single index route): + - [app.tsx](/home/drew/code/dapp/apps/leaderboard/src/app/app.tsx#L19) +- Immediate design quality opportunity is not style mismatch, but trust/clarity: + - fix pagination truth + - fix sync indicator semantics + - align copy and data filters to production policy + +## Recommended Remediation Checklist + +### P0 (Do Now) + +- [ ] Add aggregate count query for leaderboard pagination (or disable fake total pagination). +- [ ] Replace unbounded role-account query with server-side role-filtered leaderboard query strategy. +- [ ] Make sync indicator explicit (`indexed block`, `network head`, `lag`) or mark as “index activity” instead of “synced”. + +### P1 (Next) + +- [ ] Add leaderboard tests: + - pagination/total-count behavior + - role filter correctness + - endpoint fallback behavior + - sync indicator rendering states +- [ ] Move leaderboard endpoint resolution to shared `executeEnvioGraphQL` utility to avoid drift. +- [ ] Configure real team account exclusion list via env/config instead of hardcoded placeholder. + +### P2 (Cleanup) + +- [ ] Update leaderboard hero copy to current protocol terminology. +- [ ] Performance pass: chunking strategy + heavy icon/network asset lazy loading. + From 2379bb556365cee2f48f21f1b8ab6464faa6668c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 5 Mar 2026 13:00:48 -0700 Subject: [PATCH 2/4] fix(leaderboard): modernize indexer queries and remove legacy wording --- apps/leaderboard/index.html | 2 +- .../components/SyncProgressIndicator.tsx | 74 +-- .../queries/indexingProgressQuery.ts | 62 +-- .../components/LeaderboardTable.tsx | 43 +- .../leaderboard/queries/leaderboardQuery.ts | 506 ++++++++++++++---- apps/leaderboard/src/pages/index.tsx | 4 +- docs/app-state-audit-2026-03-05.md | 18 +- 7 files changed, 485 insertions(+), 224 deletions(-) diff --git a/apps/leaderboard/index.html b/apps/leaderboard/index.html index f81250889..9103c5ddc 100644 --- a/apps/leaderboard/index.html +++ b/apps/leaderboard/index.html @@ -13,7 +13,7 @@ diff --git a/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx b/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx index 30947e1d7..d3ae38cf0 100644 --- a/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx +++ b/apps/leaderboard/src/features/indexingProgress/components/SyncProgressIndicator.tsx @@ -16,32 +16,12 @@ export const SyncProgressIndicator = ({ }) => { const { data, error, isPending } = useIndexingProgress(network); - const progress = useMemo(() => { - if (!data?.lastProcessedHeight || !data?.targetHeight) { - return 0; - } - - // Round to 2 decimal places - return ( - Math.round((data.lastProcessedHeight / data.targetHeight) * 100 * 100) / - 100 - ); - }, [data?.lastProcessedHeight, data?.targetHeight]); - - const isSynced = useMemo(() => { - if (!data?.lastProcessedHeight || !data?.targetHeight) { - return false; - } - - return data.lastProcessedHeight === data.targetHeight; - }, [data?.lastProcessedHeight, data?.targetHeight]); - const displayContent = useMemo(() => { if (isPending) { return ( <> - Loading indexing status... + Loading indexer activity... ); } @@ -50,53 +30,41 @@ export const SyncProgressIndicator = ({ return ( <> - Error loading indexing status + Indexer status unavailable + + ); + } + + if (!data) { + return ( + <> + + No indexer metadata ); } return ( <> - + - {isSynced ? 'Synced' : 'Indexing'} - + Indexed block - {data?.lastProcessedHeight ? ( - - ) : ( - EMPTY_VALUE_PLACEHOLDER - )} + - - of - - - {data?.targetHeight ? ( - + + ({' '} + {data.numEventsProcessed > 0 ? ( + ) : ( EMPTY_VALUE_PLACEHOLDER - )} - - - - ( - %) + )}{' '} + events) ); - }, [ - isPending, - error, - isSynced, - data?.lastProcessedHeight, - data?.targetHeight, - progress, - ]); + }, [isPending, error, data]); return ( { - if (network === 'MAINNET') { - return ( - import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); - } - return ( - import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); -}; - interface ChainMetadataRow { first_event_block_number: number; latest_processed_block: number; @@ -40,30 +32,17 @@ interface ChainMetadataRow { chain_id: number; } +const toEnvioNetwork = (network: NetworkType): EnvioNetwork => { + return network === 'MAINNET' ? 'mainnet' : 'testnet'; +}; + const fetcher = async ( network: NetworkType, ): Promise => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - query: INDEXING_PROGRESS_QUERY, - }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const result = (await response.json()) as { - data: { chain_metadata: ChainMetadataRow[] }; - errors?: Array<{ message: string }>; - }; + const result = await executeEnvioGraphQL< + { chain_metadata: ChainMetadataRow[] }, + Record + >(INDEXING_PROGRESS_QUERY, {}, toEnvioNetwork(network)); if (result.errors?.length) { console.warn('GraphQL errors:', result.errors); @@ -75,10 +54,11 @@ const fetcher = async ( return null; } - // Envio tracks latest_processed_block, we estimate target as a bit ahead return { - lastProcessedHeight: metadata.latest_processed_block, - targetHeight: metadata.latest_processed_block + 1, // Estimate target + firstEventBlockNumber: metadata.first_event_block_number, + latestProcessedBlock: metadata.latest_processed_block, + numEventsProcessed: metadata.num_events_processed, + chainId: metadata.chain_id, }; }; diff --git a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx index 90f909cb2..ffc812d83 100644 --- a/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx +++ b/apps/leaderboard/src/features/leaderboard/components/LeaderboardTable.tsx @@ -26,7 +26,7 @@ import { useReactTable, } from '@tanstack/react-table'; import cx from 'classnames'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { useLatestTimestamp } from '../../../queries'; import { SyncProgressIndicator } from '../../indexingProgress'; @@ -35,6 +35,7 @@ import { getAccountIdsForRoles, useLeaderboard, useRoleAccounts, + useRoleCounts, } from '../queries'; import { Account } from '../types'; import { createAccountExplorerUrl } from '../utils/createAccountExplorerUrl'; @@ -191,8 +192,10 @@ export const LeaderboardTable = () => { error: timestampError, } = useLatestTimestamp(networkTab); + const { data: roleCounts, isPending: isRoleCountsPending } = + useRoleCounts(networkTab); const { data: roleAccounts, isPending: isRoleAccountsPending } = - useRoleAccounts(networkTab); + useRoleAccounts(networkTab, selectedRoles); const roleFilteredAccountIds = useMemo(() => { if (selectedRoles.length === 0 || !roleAccounts) { @@ -201,15 +204,11 @@ export const LeaderboardTable = () => { return getAccountIdsForRoles(roleAccounts, selectedRoles); }, [selectedRoles, roleAccounts]); - const roleCounts = useMemo(() => { - if (!roleAccounts) return undefined; - return { - operators: roleAccounts.operators.size, - stakers: roleAccounts.stakers.size, - developers: roleAccounts.developers.size, - customers: roleAccounts.customers.size, - }; - }, [roleAccounts]); + useEffect(() => { + setPagination((prev) => + prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }, + ); + }, [searchQuery, networkTab]); // Calculate timestamp for 7 days ago (Envio uses timestamps instead of block numbers) const timestampSevenDaysAgo = useMemo(() => { @@ -317,6 +316,19 @@ export const LeaderboardTable = () => { roleFilteredAccountIds, ]); + const totalRecords = useMemo(() => { + const roleFilteringActive = selectedRoles.length > 0; + if (shouldUseClientSideFiltering || roleFilteringActive) { + return data.length; + } + return leaderboardData?.totalCount ?? data.length; + }, [ + shouldUseClientSideFiltering, + selectedRoles.length, + data.length, + leaderboardData?.totalCount, + ]); + const table = useReactTable({ data, columns: COLUMNS, @@ -326,7 +338,7 @@ export const LeaderboardTable = () => { expanded, pagination, }, - rowCount: leaderboardData?.totalCount, + rowCount: totalRecords, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), onExpandedChange: setExpanded, @@ -373,7 +385,10 @@ export const LeaderboardTable = () => { selectedRoles={selectedRoles} onRoleToggle={handleRoleToggle} onClearAll={handleClearRoles} - isLoading={isRoleAccountsPending} + isLoading={ + isRoleCountsPending || + (selectedRoles.length > 0 && isRoleAccountsPending) + } roleCounts={roleCounts} /> @@ -424,7 +439,7 @@ export const LeaderboardTable = () => { { + const configured = import.meta.env.VITE_LEADERBOARD_EXCLUDED_ACCOUNTS as + | string + | undefined; + + if (!configured || configured.trim().length === 0) { + return [...DEFAULT_EXCLUDED_ACCOUNT_IDS]; + } + + const parsed = configured + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + + if (parsed.length === 0) { + return [...DEFAULT_EXCLUDED_ACCOUNT_IDS]; + } + + return [...new Set(parsed)]; +}; + +const EXCLUDED_LEADERBOARD_ACCOUNT_IDS = parseExcludedLeaderboardAccountIds(); +const EXCLUDED_LEADERBOARD_ACCOUNT_SET = new Set( + EXCLUDED_LEADERBOARD_ACCOUNT_IDS, +); + +const toEnvioNetwork = (network: NetworkType): EnvioNetwork => { + return network === 'MAINNET' ? 'mainnet' : 'testnet'; +}; + +const isAggregateFieldUnavailable = ( + errors?: Array<{ message: string }>, +): boolean => { + if (!errors || errors.length === 0) { + return false; + } + + return errors.some((error) => { + const message = error.message.toLowerCase(); + return ( + message.includes('cannot query field') && message.includes('_aggregate') + ); + }); +}; + /** - * PointsAccount node from NVO indexer + * PointsAccount node from Envio indexer */ export interface LeaderboardAccountNodeType { id: string; @@ -64,6 +112,15 @@ export interface LeaderboardAccountWithActivity interface LeaderboardQueryResponse { PointsAccount: LeaderboardAccountNodeType[]; + PointsAccount_aggregate?: { + aggregate?: { + count: number; + }; + }; +} + +interface LeaderboardFallbackCountResponse { + PointsAccount: Array<{ id: string }>; } interface ActivityQueryResponse { @@ -74,31 +131,46 @@ interface ActivityQueryResponse { JobCall: Array<{ id: string; caller: string }>; } -const getEndpoint = (network: NetworkType): string => { - if (network === 'MAINNET') { - return ( - import.meta.env.VITE_ENVIO_MAINNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); - } - return ( - import.meta.env.VITE_ENVIO_TESTNET_ENDPOINT || - 'http://localhost:8080/v1/graphql' - ); -}; +interface RoleAccountsResponse { + Operator?: Array<{ id: string }>; + Delegator?: Array<{ id: string }>; + Blueprint?: Array<{ owner: string }>; + JobCall?: Array<{ caller: string }>; +} + +interface RoleCountsResponse { + Operator_aggregate?: { + aggregate?: { count: number }; + }; + Delegator_aggregate?: { + aggregate?: { count: number }; + }; + Blueprint_aggregate?: { + aggregate?: { count: number }; + }; + JobCall_aggregate?: { + aggregate?: { count: number }; + }; +} const LEADERBOARD_QUERY = ` query LeaderboardQuery( $limit: Int! $offset: Int! $timestampSevenDaysAgo: numeric! - $accountIdQuery: String + $accountIdQuery: String! + $excludedAccountIds: [String!] ) { PointsAccount( limit: $limit offset: $offset order_by: { leaderboardPoints: desc } - where: { id: { _ilike: $accountIdQuery } } + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } ) { id totalPoints @@ -116,6 +188,40 @@ const LEADERBOARD_QUERY = ` totalPoints } } + PointsAccount_aggregate( + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } + ) { + aggregate { + count + } + } + } +`; + +const LEADERBOARD_COUNT_FALLBACK_QUERY = ` + query LeaderboardCountFallback( + $limit: Int! + $offset: Int! + $accountIdQuery: String! + $excludedAccountIds: [String!] + ) { + PointsAccount( + limit: $limit + offset: $offset + where: { + id: { + _ilike: $accountIdQuery + _nin: $excludedAccountIds + } + } + ) { + id + } } `; @@ -159,6 +265,130 @@ const ACTIVITY_QUERY = ` } `; +const ROLE_ACCOUNTS_QUERY = ` + query RoleAccounts( + $includeOperators: Boolean! + $includeStakers: Boolean! + $includeDevelopers: Boolean! + $includeCustomers: Boolean! + ) { + Operator @include(if: $includeOperators) { + id + } + Delegator( + where: { + _or: [ + { totalDeposited: { _gt: "0" } } + { totalDelegated: { _gt: "0" } } + ] + } + ) @include(if: $includeStakers) { + id + } + Blueprint( + distinct_on: owner + order_by: { owner: asc } + ) @include(if: $includeDevelopers) { + owner + } + JobCall( + distinct_on: caller + order_by: { caller: asc } + ) @include(if: $includeCustomers) { + caller + } + } +`; + +const ROLE_COUNTS_QUERY = ` + query RoleCounts { + Operator_aggregate { + aggregate { + count + } + } + Delegator_aggregate( + where: { + _or: [ + { totalDeposited: { _gt: "0" } } + { totalDelegated: { _gt: "0" } } + ] + } + ) { + aggregate { + count + } + } + Blueprint_aggregate { + aggregate { + count(columns: owner, distinct: true) + } + } + JobCall_aggregate { + aggregate { + count(columns: caller, distinct: true) + } + } + } +`; + +const normalizeAccountIds = (accounts: string[]): Set => { + return new Set( + accounts + .map((entry) => entry.toLowerCase()) + .filter((entry) => !EXCLUDED_LEADERBOARD_ACCOUNT_SET.has(entry)), + ); +}; + +const computeFallbackTotalCount = async ( + envioNetwork: EnvioNetwork, + accountIdQuery: string, +): Promise => { + const fallbackPageSize = 1000; + let totalCount = 0; + let offset = 0; + + while (true) { + const fallbackResult = await executeEnvioGraphQL< + LeaderboardFallbackCountResponse, + { + limit: number; + offset: number; + accountIdQuery: string; + excludedAccountIds: string[]; + } + >( + LEADERBOARD_COUNT_FALLBACK_QUERY, + { + limit: fallbackPageSize, + offset, + accountIdQuery, + excludedAccountIds: EXCLUDED_LEADERBOARD_ACCOUNT_IDS, + }, + envioNetwork, + ); + + if (fallbackResult.errors?.length) { + throw new Error( + `Failed to fetch fallback leaderboard count: ${fallbackResult.errors + .map((error) => error.message) + .join('; ')}`, + ); + } + + const pageSize = fallbackResult.data.PointsAccount.length; + totalCount += pageSize; + + if (pageSize < fallbackPageSize) { + break; + } + + offset += fallbackPageSize; + } + + return totalCount; +}; + const fetchLeaderboard = async ( network: NetworkType, limit: number, @@ -166,42 +396,55 @@ const fetchLeaderboard = async ( timestampSevenDaysAgo: number, accountIdQuery?: string, ): Promise<{ nodes: LeaderboardAccountNodeType[]; totalCount: number }> => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + const envioNetwork = toEnvioNetwork(network); + const accountQuery = accountIdQuery ? `%${accountIdQuery}%` : '%%'; + + const result = await executeEnvioGraphQL< + LeaderboardQueryResponse, + { + limit: number; + offset: number; + timestampSevenDaysAgo: number; + accountIdQuery: string; + excludedAccountIds: string[]; + } + >( + LEADERBOARD_QUERY, + { + limit, + offset, + timestampSevenDaysAgo, + accountIdQuery: accountQuery, + excludedAccountIds: EXCLUDED_LEADERBOARD_ACCOUNT_IDS, }, - body: JSON.stringify({ - query: LEADERBOARD_QUERY, - variables: { - limit, - offset, - timestampSevenDaysAgo, - accountIdQuery: accountIdQuery ? `%${accountIdQuery}%` : '%%', - }, - }), - }); + envioNetwork, + ); - if (!response.ok) { - throw new Error('Network response was not ok'); + if (result.errors?.length && !isAggregateFieldUnavailable(result.errors)) { + throw new Error( + `Failed to fetch leaderboard data: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: LeaderboardQueryResponse }; + const nodes = result.data.PointsAccount; - // Filter out team accounts - const filteredAccounts = result.data.PointsAccount.filter( - (account) => - !TEAM_ACCOUNTS.includes( - account.id.toLowerCase() as (typeof TEAM_ACCOUNTS)[number], - ), + if (!result.errors?.length) { + return { + nodes, + totalCount: result.data.PointsAccount_aggregate?.aggregate?.count ?? 0, + }; + } + + const totalCount = await computeFallbackTotalCount( + envioNetwork, + accountQuery, ); return { - nodes: filteredAccounts, - totalCount: filteredAccounts.length, + nodes, + totalCount, }; }; @@ -209,25 +452,19 @@ const fetchAccountActivity = async ( network: NetworkType, accountId: string, ): Promise => { - const endpoint = getEndpoint(network); - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - query: ACTIVITY_QUERY, - variables: { accountId }, - }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); + const result = await executeEnvioGraphQL< + ActivityQueryResponse, + { accountId: string } + >(ACTIVITY_QUERY, { accountId }, toEnvioNetwork(network)); + + if (result.errors?.length) { + throw new Error( + `Failed to fetch account activity: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: ActivityQueryResponse }; return result.data; }; @@ -249,6 +486,7 @@ export function useLeaderboard( offset, timestampSevenDaysAgo, accountIdQuery, + EXCLUDED_LEADERBOARD_ACCOUNT_IDS.join(','), ], queryFn: () => fetchLeaderboard( @@ -273,33 +511,6 @@ export function useAccountActivity(network: NetworkType, accountId: string) { }); } -const ROLE_ACCOUNTS_QUERY = ` - query RoleAccounts { - Operator { - id - } - Delegator(where: { _or: [ - { totalDeposited: { _gt: "0" } }, - { totalDelegated: { _gt: "0" } } - ]}) { - id - } - Blueprint { - owner - } - JobCall { - caller - } - } -`; - -interface RoleAccountsResponse { - Operator: Array<{ id: string }>; - Delegator: Array<{ id: string }>; - Blueprint: Array<{ owner: string }>; - JobCall: Array<{ caller: string }>; -} - export interface RoleAccountsData { operators: Set; stakers: Set; @@ -307,35 +518,92 @@ export interface RoleAccountsData { customers: Set; } +export interface RoleCountsData { + operators: number; + stakers: number; + developers: number; + customers: number; +} + +const fetchRoleCounts = async ( + network: NetworkType, +): Promise => { + const result = await executeEnvioGraphQL< + RoleCountsResponse, + Record + >(ROLE_COUNTS_QUERY, {}, toEnvioNetwork(network)); + + if (result.errors?.length) { + // Keep role filtering functional even if aggregate capabilities vary by environment. + return undefined; + } + + return { + operators: result.data.Operator_aggregate?.aggregate?.count ?? 0, + stakers: result.data.Delegator_aggregate?.aggregate?.count ?? 0, + developers: result.data.Blueprint_aggregate?.aggregate?.count ?? 0, + customers: result.data.JobCall_aggregate?.aggregate?.count ?? 0, + }; +}; + const fetchRoleAccounts = async ( network: NetworkType, + selectedRoles: RoleFilterEnum[], ): Promise => { - const endpoint = getEndpoint(network); + if (selectedRoles.length === 0) { + return { + operators: new Set(), + stakers: new Set(), + developers: new Set(), + customers: new Set(), + }; + } - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', + const includeOperators = selectedRoles.includes(RoleFilterEnum.OPERATOR); + const includeStakers = selectedRoles.includes(RoleFilterEnum.STAKER); + const includeDevelopers = selectedRoles.includes(RoleFilterEnum.DEVELOPER); + const includeCustomers = selectedRoles.includes(RoleFilterEnum.CUSTOMER); + + const result = await executeEnvioGraphQL< + RoleAccountsResponse, + { + includeOperators: boolean; + includeStakers: boolean; + includeDevelopers: boolean; + includeCustomers: boolean; + } + >( + ROLE_ACCOUNTS_QUERY, + { + includeOperators, + includeStakers, + includeDevelopers, + includeCustomers, }, - body: JSON.stringify({ - query: ROLE_ACCOUNTS_QUERY, - }), - }); + toEnvioNetwork(network), + ); - if (!response.ok) { - throw new Error('Network response was not ok'); + if (result.errors?.length) { + throw new Error( + `Failed to fetch role accounts: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); } - const result = (await response.json()) as { data: RoleAccountsResponse }; - return { - operators: new Set(result.data.Operator.map((o) => o.id.toLowerCase())), - stakers: new Set(result.data.Delegator.map((d) => d.id.toLowerCase())), - developers: new Set( - result.data.Blueprint.map((b) => b.owner.toLowerCase()), + operators: normalizeAccountIds( + (result.data.Operator ?? []).map((item) => item.id), + ), + stakers: normalizeAccountIds( + (result.data.Delegator ?? []).map((item) => item.id), + ), + developers: normalizeAccountIds( + (result.data.Blueprint ?? []).map((item) => item.owner), + ), + customers: normalizeAccountIds( + (result.data.JobCall ?? []).map((item) => item.caller), ), - customers: new Set(result.data.JobCall.map((j) => j.caller.toLowerCase())), }; }; @@ -369,10 +637,24 @@ export const getAccountIdsForRoles = ( return accountIds; }; -export function useRoleAccounts(network: NetworkType) { +export function useRoleAccounts( + network: NetworkType, + selectedRoles: RoleFilterEnum[], +) { + const sortedRoles = [...selectedRoles].sort(); + return useQuery({ - queryKey: ['roleAccounts', network], - queryFn: () => fetchRoleAccounts(network), + queryKey: ['roleAccounts', network, sortedRoles.join(',')], + queryFn: () => fetchRoleAccounts(network, sortedRoles), + enabled: sortedRoles.length > 0, staleTime: 30_000, }); } + +export function useRoleCounts(network: NetworkType) { + return useQuery({ + queryKey: ['roleCounts', network], + queryFn: () => fetchRoleCounts(network), + staleTime: 60_000, + }); +} diff --git a/apps/leaderboard/src/pages/index.tsx b/apps/leaderboard/src/pages/index.tsx index 4eabe0f59..cc03399f5 100644 --- a/apps/leaderboard/src/pages/index.tsx +++ b/apps/leaderboard/src/pages/index.tsx @@ -14,8 +14,8 @@ export default function IndexPage() { Tangle leaderboard ranks contributors based on experience points (XP) - earned from network activities like staking, nominating, and running - services.{' '} + earned from network activities like staking, deploying blueprints, and + running services.{' '}