diff --git a/staking-dashboard/src/components/MainContent/ProvidersSection.tsx b/staking-dashboard/src/components/MainContent/ProvidersSection.tsx index b58c4ca39..d79942589 100644 --- a/staking-dashboard/src/components/MainContent/ProvidersSection.tsx +++ b/staking-dashboard/src/components/MainContent/ProvidersSection.tsx @@ -1,12 +1,10 @@ -import { useMemo } from "react" import { ProviderTable } from "@/components/Provider/ProviderTable" import { ProviderSearch } from "@/components/Provider/ProviderSearch" import { Pagination } from "@/components/Pagination" import { DecentralizationDisclaimer } from "@/components/DecentralizationDisclaimer" import { useProviderTable } from "@/hooks/providers/useProviderTable" +import { useProviderTableDisplayData } from "@/hooks/providers/useProviderTableDisplayData" import { useProviderDisclaimer } from "@/hooks/providers/useProviderDisclaimer" -import { useAggregatedStakingData } from "@/hooks/atp/useAggregatedStakingData" -import { useMultipleProviderQueueLengths, useMultipleProviderConfigurations } from "@/hooks/stakingRegistry" import { applyHeroItalics } from "@/utils/typographyUtils" /** @@ -20,6 +18,7 @@ export const ProvidersSection = () => { isLoading: isLoadingProviders, sortField, sortDirection, + hasUserSorted, handleSort, searchQuery, handleSearchChange, @@ -37,44 +36,21 @@ export const ProvidersSection = () => { handleDisclaimerCancel } = useProviderDisclaimer(allProviders) - const { delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown } = useAggregatedStakingData() - - // Create a map of providerId to total delegated amount (excluding failed deposits and unstaked) - // Includes both ATP delegations, direct stakes, and ERC20 delegations - const myDelegations = useMemo(() => { - const delegationMap = new Map() - - // Add ATP delegations (exclude failed and unstaked) - delegationBreakdown - .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') - .forEach(delegation => { - const current = delegationMap.get(delegation.providerId) || 0n - delegationMap.set(delegation.providerId, current + delegation.stakedAmount) - }) - - // Add direct stakes that match provider self-stakes (exclude failed and unstaked) - directStakeBreakdown - .filter(stake => stake.providerId !== undefined && !stake.hasFailedDeposit && stake.status !== 'UNSTAKED') - .forEach(stake => { - const current = delegationMap.get(stake.providerId!) || 0n - delegationMap.set(stake.providerId!, current + stake.stakedAmount) - }) - - // Add ERC20 delegations (exclude failed and unstaked) - erc20DelegationBreakdown - .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') - .forEach(delegation => { - const current = delegationMap.get(delegation.providerId) || 0n - delegationMap.set(delegation.providerId, current + delegation.stakedAmount) - }) - - return delegationMap - }, [delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown]) - - // Get queue lengths and configurations for all providers - const providerIds = useMemo(() => providers.map(v => Number(v.id)), [providers]) - const { queueLengths } = useMultipleProviderQueueLengths(providerIds) - const { configurations } = useMultipleProviderConfigurations(providerIds) + const { + myDelegations, + queueLengths, + configurations, + topGroupSize, + showDecentralizationBar, + topGroupSizeThreshold, + } = useProviderTableDisplayData({ + providers, + sortField, + sortDirection, + currentPage, + searchQuery, + hasUserSorted, + }) return (
@@ -123,6 +99,9 @@ export const ProvidersSection = () => { queueLengths={queueLengths} notAssociatedStake={notAssociatedStake} providerConfigurations={configurations} + topGroupSize={topGroupSize} + showDecentralizationBar={showDecentralizationBar} + decentralizationBarAfterCount={topGroupSizeThreshold} />
diff --git a/staking-dashboard/src/components/Provider/ProviderTable.tsx b/staking-dashboard/src/components/Provider/ProviderTable.tsx index 4c7e8f761..d8cdc97a9 100644 --- a/staking-dashboard/src/components/Provider/ProviderTable.tsx +++ b/staking-dashboard/src/components/Provider/ProviderTable.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react" import { Table, TableBody, @@ -34,10 +35,155 @@ interface ProviderTableProps { queueLengths?: Map notAssociatedStake?: NotAssociatedStake providerConfigurations?: Map + /** Number of top providers to collapse into a group row. Set to 0 to disable. */ + topGroupSize?: number + /** Whether to show the decentralization separator banner. */ + showDecentralizationBar?: boolean + /** Row count after which to place the decentralization bar when not grouped. */ + decentralizationBarAfterCount?: number +} + +interface ProviderRowProps { + provider: ProviderListItem + config?: ProviderConfiguration + myDelegations?: Map + queueLengths?: Map + decimals?: number + symbol?: string + isLoadingTokenDetails: boolean + onStakeClick: (provider: ProviderListItem, event: React.MouseEvent) => void +} + +function DecentralizationBarRow() { + return ( + + +
+ Improve decentralization and network health by staking with a group below ↓ +
+ + + ) +} + +function ProviderRow({ + provider, + config, + myDelegations, + queueLengths, + decimals, + symbol, + isLoadingTokenDetails, + onStakeClick, +}: ProviderRowProps) { + const navigate = useNavigate() + const displayAddress = config?.providerAdmin && config.providerAdmin !== zeroAddress + ? config.providerAdmin + : provider.address + + return ( + + +
+ +
+ +
+ + {displayAddress?.slice(0, 8)}...{displayAddress?.slice(-6)} + + +
+
+
+
+ +
+
+ {isLoadingTokenDetails ? ( +
+
+ Loading... +
+ ) : decimals ? ( + formatTokenAmount(stringToBigInt(provider.totalStaked), decimals, symbol) + ) : ( + provider.totalStaked + )} +
+
+ {provider.percentage} +
+
+
+ +
+
+ {provider.cumulativePercentage} +
+
+
+
+
+ + +
+ {config?.providerTakeRate !== undefined + ? `${formatBipsToPercentage(config.providerTakeRate)}%` + : `${formatBipsToPercentage(provider.commission)}%`} +
+
+ + {(() => { + const myDelegation = myDelegations?.get(Number(provider.id)) || 0n + return myDelegation > 0n ? ( +
+ {isLoadingTokenDetails ? "..." : decimals ? formatTokenAmount(myDelegation, decimals, symbol) : "-"} +
+ ) : ( +
-
+ ) + })()} +
+ + {(() => { + const queueLength = queueLengths?.get(Number(provider.id)) ?? 0 + const hasSequencerKeys = queueLength > 0 + + return ( + + ) + })()} + + + ) } /** - * Table component for displaying staking providers with sorting and search + * Table component for displaying staking providers with sorting, search, and + * an optional top-group row that collapses the highest-stake providers. */ export const ProviderTable = ({ providers, @@ -49,10 +195,42 @@ export const ProviderTable = ({ myDelegations, queueLengths, notAssociatedStake, - providerConfigurations + providerConfigurations, + topGroupSize = 0, + showDecentralizationBar = false, + decentralizationBarAfterCount = 0, }: ProviderTableProps) => { const { symbol, decimals, isLoading: isLoadingTokenDetails } = useStakingAssetTokenDetails() - const navigate = useNavigate() + const [isGroupExpanded, setIsGroupExpanded] = useState(false) + + // Collapse group whenever it resets (sort/page changes) + useEffect(() => { + setIsGroupExpanded(false) + }, [topGroupSize]) + + const shouldShowGroup = topGroupSize > 0 && providers.length > topGroupSize && !isGroupExpanded + const groupProviders = shouldShowGroup ? providers.slice(0, topGroupSize) : [] + const restProviders = shouldShowGroup ? providers.slice(topGroupSize) : providers + const shouldShowInlineBar = + !shouldShowGroup && + showDecentralizationBar && + decentralizationBarAfterCount > 0 && + providers.length > decentralizationBarAfterCount + const inlineTopProviders = shouldShowInlineBar ? providers.slice(0, decentralizationBarAfterCount) : [] + const inlineBottomProviders = shouldShowInlineBar ? providers.slice(decentralizationBarAfterCount) : [] + + // Aggregate stats for the collapsed group row + const groupTotalStaked = groupProviders.reduce( + (sum, p) => sum + stringToBigInt(p.totalStaked), + 0n, + ) + const groupCumulativePercentage = groupProviders[groupProviders.length - 1]?.cumulativePercentage ?? '0%' + const groupMyStake = groupProviders.reduce( + (sum, p) => sum + (myDelegations?.get(Number(p.id)) ?? 0n), + 0n, + ) + + const sharedRowProps = { decimals, symbol, isLoadingTokenDetails, onStakeClick, myDelegations, queueLengths } return ( @@ -203,113 +381,152 @@ export const ProviderTable = ({ )) ) : providers.length > 0 ? ( - providers.map((provider) => { - // Get on-chain configuration for this provider - const config = providerConfigurations?.get(Number(provider.id)) - const displayAddress = config?.providerAdmin && config.providerAdmin !== zeroAddress - ? config.providerAdmin - : provider.address - - return ( - - -
- -
- -
- - {displayAddress?.slice(0, 8)}...{displayAddress?.slice(-6)} - - + <> + {/* ── Top-group row ── */} + {shouldShowGroup && ( + <> + {/* Collapsed / expanded toggle row */} + setIsGroupExpanded(true)} + > + +
+ {/* Stacked provider avatars */} +
+ {groupProviders.slice(0, 3).map((p, i) => ( +
0 ? '-8px' : '0', zIndex: 3 - i, position: 'relative' }} + > + +
+ ))} + {topGroupSize > 3 && ( +
+ +{topGroupSize - 3} +
+ )}
-
-
- - -
-
- {isLoadingTokenDetails ? ( -
-
- Loading... + +
+
+ 1–{topGroupSize}
- ) : decimals ? ( - formatTokenAmount(stringToBigInt(provider.totalStaked), decimals, symbol) - ) : ( - provider.totalStaked - )} -
-
- {provider.percentage} +
+ Top {topGroupSize} providers +
+
+ +
-
- - -
-
- {provider.cumulativePercentage} + + + {/* Combined total stake */} + +
+
+ {isLoadingTokenDetails ? ( +
+
+ Loading... +
+ ) : decimals ? ( + formatTokenAmount(groupTotalStaked, decimals, symbol) + ) : ( + String(groupTotalStaked) + )} +
+
combined
- {/* Cumulative progress bar */} -
-
+ + + {/* Cumulative % of the last provider in the group */} + +
+
+ {groupCumulativePercentage} +
+
+
+
-
-
- -
- {config?.providerTakeRate !== undefined - ? `${formatBipsToPercentage(config.providerTakeRate)}%` - : `${formatBipsToPercentage(provider.commission)}%`} -
-
- - {(() => { - const myDelegation = myDelegations?.get(Number(provider.id)) || 0n - return myDelegation > 0n ? ( + + + {/* Commission – not meaningful for a group */} + +
+
+ + {/* My combined stake across group providers */} + + {groupMyStake > 0n ? (
- {isLoadingTokenDetails ? "..." : decimals ? formatTokenAmount(myDelegation, decimals, symbol) : "-"} + {isLoadingTokenDetails ? "..." : decimals ? formatTokenAmount(groupMyStake, decimals, symbol) : "–"}
) : ( -
-
- ) - })()} -
- - {(() => { - const queueLength = queueLengths?.get(Number(provider.id)) ?? 0 - const hasSequencerKeys = queueLength > 0 +
+ )} +
+ + {/* No direct action on a group */} + + + - return ( - - ) - })()} - - - ) - }) + + )} + + {/* Decentralization separator */} + {showDecentralizationBar && !shouldShowInlineBar && ( + + )} + + {/* Individual provider rows (providers after the group, or all if no group) */} + {shouldShowInlineBar ? ( + <> + {inlineTopProviders.map((provider) => ( + + ))} + + + + {inlineBottomProviders.map((provider) => ( + + ))} + + ) : ( + restProviders.map((provider) => ( + + )) + )} + ) : ( @@ -396,4 +613,4 @@ export const ProviderTable = ({
) -} \ No newline at end of file +} diff --git a/staking-dashboard/src/hooks/providers/useProviderTable.ts b/staking-dashboard/src/hooks/providers/useProviderTable.ts index 66b897d18..1bb9a7c9c 100644 --- a/staking-dashboard/src/hooks/providers/useProviderTable.ts +++ b/staking-dashboard/src/hooks/providers/useProviderTable.ts @@ -118,6 +118,7 @@ export const useProviderTable = () => { const [searchQuery, setSearchQuery] = useState("") const [sortField, setSortField] = useState('totalStaked') const [sortDirection, setSortDirection] = useState('desc') + const [hasUserSorted, setHasUserSorted] = useState(false) const tableTopRef = useRef(null) const itemsPerPage = 10 @@ -130,16 +131,17 @@ export const useProviderTable = () => { retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) }) - const providers = providersData?.providers ?? [] + const providers = providersData?.providers const totalStaked = providersData?.totalStaked ?? '' const rawNotAssociatedStake = providersData?.notAssociatedStake // Format API data to match expected structure with percentages const allProviders = useMemo(() => { + const providerList = providers ?? [] const networkTotalStake = parseFloat(totalStaked) // Sort by stake descending to calculate cumulative percentages - const sortedProviders = [...providers].sort((a, b) => + const sortedProviders = [...providerList].sort((a, b) => parseFloat(b.totalStaked ?? '0') - parseFloat(a.totalStaked ?? '0') ) @@ -181,6 +183,7 @@ export const useProviderTable = () => { }, [rawNotAssociatedStake, totalStaked, allProviders]) const handleSort = (field: SortField) => { + setHasUserSorted(true) if (sortField === field) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') } else { @@ -241,8 +244,9 @@ export const useProviderTable = () => { sortField, sortDirection, + hasUserSorted, handleSort, tableTopRef } -} \ No newline at end of file +} diff --git a/staking-dashboard/src/hooks/providers/useProviderTableDisplayData.ts b/staking-dashboard/src/hooks/providers/useProviderTableDisplayData.ts new file mode 100644 index 000000000..e29be54ea --- /dev/null +++ b/staking-dashboard/src/hooks/providers/useProviderTableDisplayData.ts @@ -0,0 +1,91 @@ +import { useMemo } from "react" +import { useAggregatedStakingData } from "@/hooks/atp/useAggregatedStakingData" +import { useMultipleProviderQueueLengths, useMultipleProviderConfigurations } from "@/hooks/stakingRegistry" +import { type ProviderListItem, type SortDirection, type SortField } from "@/hooks/providers/useProviderTable" + +interface UseProviderTableDisplayDataParams { + providers: ProviderListItem[] + sortField: SortField + sortDirection: SortDirection + currentPage: number + searchQuery: string + hasUserSorted: boolean +} + +const TOP_GROUP_SIZE = 5 + +/** + * Shared display data for provider table entry points. + * Keeps grouping and delegation calculations consistent across screens. + */ +export function useProviderTableDisplayData({ + providers, + sortField, + sortDirection, + currentPage, + searchQuery, + hasUserSorted, +}: UseProviderTableDisplayDataParams) { + const { delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown } = useAggregatedStakingData() + + // Create a map of providerId to total delegated amount (excluding failed deposits and unstaked) + // Includes both ATP delegations, direct stakes, and ERC20 delegations + const myDelegations = useMemo(() => { + const delegationMap = new Map() + + // Add ATP delegations (exclude failed and unstaked) + delegationBreakdown + .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') + .forEach(delegation => { + const current = delegationMap.get(delegation.providerId) || 0n + delegationMap.set(delegation.providerId, current + delegation.stakedAmount) + }) + + // Add direct stakes that match provider self-stakes (exclude failed and unstaked) + directStakeBreakdown + .filter(stake => stake.providerId !== undefined && !stake.hasFailedDeposit && stake.status !== 'UNSTAKED') + .forEach(stake => { + const current = delegationMap.get(stake.providerId!) || 0n + delegationMap.set(stake.providerId!, current + stake.stakedAmount) + }) + + // Add ERC20 delegations (exclude failed and unstaked) + erc20DelegationBreakdown + .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') + .forEach(delegation => { + const current = delegationMap.get(delegation.providerId) || 0n + delegationMap.set(delegation.providerId, current + delegation.stakedAmount) + }) + + return delegationMap + }, [delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown]) + + // Get queue lengths and configurations for all providers + const providerIds = useMemo(() => providers.map(v => Number(v.id)), [providers]) + const { queueLengths } = useMultipleProviderQueueLengths(providerIds) + const { configurations } = useMultipleProviderConfigurations(providerIds) + + // Show the top-N group row only when on page 1, sorted by stake (default), and not searching + const showDecentralizationBar = + sortField === 'totalStaked' && + sortDirection === 'desc' && + currentPage === 1 && + !searchQuery && + providers.length > TOP_GROUP_SIZE + + const topGroupSize = + showDecentralizationBar && + !hasUserSorted + ? TOP_GROUP_SIZE + : 0 + + return { + myDelegations, + queueLengths, + configurations, + topGroupSize, + showDecentralizationBar, + topGroupSizeThreshold: TOP_GROUP_SIZE, + } +} + diff --git a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx index 834e75895..194946440 100644 --- a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx +++ b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx @@ -1,4 +1,3 @@ -import { useMemo } from "react" import { Link } from "react-router-dom" import { Icon } from "@/components/Icon" import { DecentralizationDisclaimer } from "@/components/DecentralizationDisclaimer" @@ -7,9 +6,8 @@ import { Pagination } from "@/components/Pagination" import { ProviderSearch } from "@/components/Provider/ProviderSearch" import { ProviderTable } from "@/components/Provider/ProviderTable" import { useProviderTable } from "@/hooks/providers/useProviderTable" +import { useProviderTableDisplayData } from "@/hooks/providers/useProviderTableDisplayData" import { useProviderDisclaimer } from "@/hooks/providers/useProviderDisclaimer" -import { useAggregatedStakingData } from "@/hooks/atp/useAggregatedStakingData" -import { useMultipleProviderQueueLengths, useMultipleProviderConfigurations } from "@/hooks/stakingRegistry" import { applyHeroItalics } from "@/utils/typographyUtils" export default function StakingProvidersPage() { @@ -23,6 +21,7 @@ export default function StakingProvidersPage() { handleSearchChange, sortField, sortDirection, + hasUserSorted, handleSort, isLoading, notAssociatedStake, @@ -36,44 +35,21 @@ export default function StakingProvidersPage() { handleDisclaimerCancel } = useProviderDisclaimer(allProviders) - const { delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown } = useAggregatedStakingData() - - // Create a map of providerId to total delegated amount (excluding failed deposits and unstaked) - // Includes both ATP delegations, direct stakes, and ERC20 delegations - const myDelegations = useMemo(() => { - const delegationMap = new Map() - - // Add ATP delegations (exclude failed and unstaked) - delegationBreakdown - .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') - .forEach(delegation => { - const current = delegationMap.get(delegation.providerId) || 0n - delegationMap.set(delegation.providerId, current + delegation.stakedAmount) - }) - - // Add direct stakes that match provider self-stakes (exclude failed and unstaked) - directStakeBreakdown - .filter(stake => stake.providerId !== undefined && !stake.hasFailedDeposit && stake.status !== 'UNSTAKED') - .forEach(stake => { - const current = delegationMap.get(stake.providerId!) || 0n - delegationMap.set(stake.providerId!, current + stake.stakedAmount) - }) - - // Add ERC20 delegations (exclude failed and unstaked) - erc20DelegationBreakdown - .filter(delegation => !delegation.hasFailedDeposit && delegation.status !== 'UNSTAKED') - .forEach(delegation => { - const current = delegationMap.get(delegation.providerId) || 0n - delegationMap.set(delegation.providerId, current + delegation.stakedAmount) - }) - - return delegationMap - }, [delegationBreakdown, directStakeBreakdown, erc20DelegationBreakdown]) - - // Get queue lengths and configurations for all providers - const providerIds = useMemo(() => providers.map(v => Number(v.id)), [providers]) - const { queueLengths } = useMultipleProviderQueueLengths(providerIds) - const { configurations } = useMultipleProviderConfigurations(providerIds) + const { + myDelegations, + queueLengths, + configurations, + topGroupSize, + showDecentralizationBar, + topGroupSizeThreshold, + } = useProviderTableDisplayData({ + providers, + sortField, + sortDirection, + currentPage, + searchQuery, + hasUserSorted, + }) return ( <> @@ -131,6 +107,9 @@ export default function StakingProvidersPage() { queueLengths={queueLengths} notAssociatedStake={notAssociatedStake} providerConfigurations={configurations} + topGroupSize={topGroupSize} + showDecentralizationBar={showDecentralizationBar} + decentralizationBarAfterCount={topGroupSizeThreshold} />
@@ -152,4 +131,4 @@ export default function StakingProvidersPage() { )} ) -} \ No newline at end of file +}