diff --git a/CLAUDE.md b/CLAUDE.md index 5b5c327f6e..799b1ea746 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,32 +45,36 @@ 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 - **api-provider-environment**: React contexts, app events, error handling - **browser-utils**: Browser utilities (fetch, download, logger, storage) - **dapp-config**: Chain/wallet configurations for dApps - **dapp-types**: Shared TypeScript types and interfaces - **icons**: Shared icon components -- **polkadot-api-provider**: Substrate/Polkadot provider for blockchain interaction +- **polkadot-api-provider**: Legacy chain provider used only by migration-claim flows - **solana-api-provider**: Solana blockchain provider - **tangle-shared-ui**: Tangle-specific logic, hooks, utilities (shared between dApps) - **ui-components**: Generic reusable UI components - **web3-api-provider**: EVM provider for blockchain interaction ### 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 ## Development Guidelines ### Execution Posture (Senior IC / Tech Lead) + - Default to ownership and execution. When a goal is clear, proceed immediately without asking permission to continue. - Prefer decisive action over proposal loops. Bring work to completion end-to-end (implementation, verification, reporting). - Escalate only for true external blockers (missing credentials, unavailable infrastructure, irreversible risk), and name the exact blocker. @@ -79,7 +83,16 @@ 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: - `http://127.0.0.1:8545` responds to `eth_chainId` with `0x7a69` (31337) @@ -91,6 +104,7 @@ yarn generate:release # Review version bumps and changelog - For local wallet runs, prefer persistent seeded profile + automated prompt settling, and ensure funding checks are active for connected local accounts. ### Code Style + - Use `const ... => {}` over `function ... () {}` - React components: `const Component: FC = ({ prop1, prop2 }) => { ... }` - Use `useMemo`/`useCallback` when appropriate (skip for simple calculations) @@ -101,40 +115,47 @@ yarn generate:release # Review version bumps and changelog - Avoid `as` type casting and `any` type ### Folder Structure (within apps) + - `utils/`: Utility functions (one function per file, same filename as function name) - `components/`: Reusable "dumb" components specific to the app - `containers/`: "Smart" components with business logic - `hooks/`: React hooks for infrastructure logic - `data/`: Data fetching hooks organized by domain (staking, liquid staking, etc.) - `pages/`: Route pages for react-router-dom -- `abi/`: EVM ABI definitions for Substrate precompiles +- `abi/`: EVM ABI definitions for precompiles/contracts ### 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 ### Branch Strategy + - Main development branch: `develop` - Main branch for releases: `master` - Release PRs should start with `[RELEASE]` in title ### Prerequisites + - Node.js v18.18.x or later - Yarn package manager (v4.7.0) ## Working with Specific Libraries ### tangle-shared-ui + Contains Tangle-specific logic shared between dApps. Use this for functionality tied to Tangle Network context. ### ui-components + Generic, reusable components not tied to any specific context. Should be usable across different dApps. ### API Providers -- Use `polkadot-api-provider` for Substrate/Polkadot interactions + +- Use `polkadot-api-provider` only where migration-claim compatibility requires it - Use `web3-api-provider` for EVM interactions - Use `abstract-api-provider` base classes when creating new providers diff --git a/apps/leaderboard/CHANGELOG.md b/apps/leaderboard/CHANGELOG.md index b43999832d..06b8991b3c 100644 --- a/apps/leaderboard/CHANGELOG.md +++ b/apps/leaderboard/CHANGELOG.md @@ -40,7 +40,7 @@ - integrate cloud credits ([#3021](https://github.com/tangle-network/dapp/pull/3021)) - **tangle-cloud:** List operators in the Operators page ([#3005](https://github.com/tangle-network/dapp/pull/3005)) - **tangle-cloud:** List all blueprints ([#2987](https://github.com/tangle-network/dapp/pull/2987)) -- **tangle-dapp:** Create restaking & services setup scripts ([#2986](https://github.com/tangle-network/dapp/pull/2986)) +- **tangle-dapp:** Create staking & services setup scripts ([#2986](https://github.com/tangle-network/dapp/pull/2986)) - **tangle-dapp:** Add blueprint selection ([#2941](https://github.com/tangle-network/dapp/pull/2941)) - **tangle-dapp:** Add Protocol Stats Component ([#2966](https://github.com/tangle-network/dapp/pull/2966)) - **tangle-dapp:** Add Phantom wallet to dApp wallet provider ([#2885](https://github.com/tangle-network/dapp/pull/2885)) @@ -48,7 +48,7 @@ ### 🩹 Fixes - **tangle-dapp:** update result type and reduce refetch interval in useCredits ([#3026](https://github.com/tangle-network/dapp/pull/3026)) -- **tangle-dapp:** Asset modal, restake action tabs & wallet dropdown fixes ([#3002](https://github.com/tangle-network/dapp/pull/3002)) +- **tangle-dapp:** Asset modal, staking action tabs & wallet dropdown fixes ([#3002](https://github.com/tangle-network/dapp/pull/3002)) - **tangle-dapp:** Fix Theme Flickering, Disable Link ([#2953](https://github.com/tangle-network/dapp/pull/2953)) ### 🏡 Chore @@ -57,7 +57,7 @@ - bump @storybook/channels from 8.6.12 to 8.6.14 ([#3025](https://github.com/tangle-network/dapp/pull/3025)) - bump @radix-ui/react-tabs from 1.1.4 to 1.1.9 ([#3006](https://github.com/tangle-network/dapp/pull/3006)) - bump @vitest/ui from 3.1.1 to 3.1.2 ([#3007](https://github.com/tangle-network/dapp/pull/3007)) -- **tangle-dapp:** Add Multiple RPC Endpoints Support for Polkadot APIs ([#2990](https://github.com/tangle-network/dapp/pull/2990)) +- **tangle-dapp:** Add multiple RPC endpoint support for chain APIs ([#2990](https://github.com/tangle-network/dapp/pull/2990)) - Add initial Cursor rules ([#2998](https://github.com/tangle-network/dapp/pull/2998)) - bump framer-motion from 12.7.2 to 12.7.4 ([#2993](https://github.com/tangle-network/dapp/pull/2993)) - bump @hookform/resolvers from 3.10.0 to 5.0.1 ([#2972](https://github.com/tangle-network/dapp/pull/2972)) @@ -67,7 +67,7 @@ - bump typescript-eslint from 8.29.1 to 8.30.0 ([#2967](https://github.com/tangle-network/dapp/pull/2967)) - **tangle-dapp:** Update asset selection modal ([#2965](https://github.com/tangle-network/dapp/pull/2965)) - bump actions/create-github-app-token from 1 to 2 ([#2961](https://github.com/tangle-network/dapp/pull/2961)) -- bump @polkadot/keyring from 13.3.1 to 13.4.3 ([#2962](https://github.com/tangle-network/dapp/pull/2962)) +- bump keyring dependency from 13.3.1 to 13.4.3 ([#2962](https://github.com/tangle-network/dapp/pull/2962)) - **tangle-dapp:** Improve Vault Table ([#2956](https://github.com/tangle-network/dapp/pull/2956)) ### 🎨 Styles @@ -100,4 +100,4 @@ ### ❤️ Thank You -- Trung-Tin Pham @AtelyPham \ No newline at end of file +- Trung-Tin Pham @AtelyPham diff --git a/apps/leaderboard/index.html b/apps/leaderboard/index.html index f81250889a..9103c5ddce 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 30947e1d72..d3ae38cf0f 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 90f909cb24..ffc812d83f 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 4eabe0f594..cc03399f57 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.{' '}