From 6d62f9619f53d3af469658c7d37747dc81fb6559 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 19 May 2026 19:54:40 +0400 Subject: [PATCH] fix: correct total stake numbers --- .../src/api/handlers/staking/summary.ts | 79 ++++++++++++++++++- atp-indexer/src/api/types/staking.types.ts | 25 ++++++ .../components/HeroSection/HeroSection.tsx | 73 +++++++++++++---- .../StakingSummaryCard/StakingSummaryCard.tsx | 67 +++++++++++++--- .../src/hooks/staking/useStakingSummary.ts | 8 ++ 5 files changed, 223 insertions(+), 29 deletions(-) diff --git a/atp-indexer/src/api/handlers/staking/summary.ts b/atp-indexer/src/api/handlers/staking/summary.ts index 80221c53f..6a95068b6 100644 --- a/atp-indexer/src/api/handlers/staking/summary.ts +++ b/atp-indexer/src/api/handlers/staking/summary.ts @@ -1,7 +1,7 @@ import type { Context } from 'hono'; import { db } from 'ponder:api'; -import { count } from 'drizzle-orm'; -import { getActivationThreshold, calculateAPR } from '../../../utils/rollup'; +import { count, sql } from 'drizzle-orm'; +import { getActivationThreshold, calculateAPR, getActiveAttesterCount } from '../../../utils/rollup'; import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; import type { StakingSummaryResponse } from '../../types/staking.types'; @@ -12,6 +12,7 @@ import { failedDeposit, provider, atpPosition, + withdrawInitiated, withdrawFinalized, deposit } from 'ponder:schema'; @@ -45,7 +46,16 @@ export async function handleStakingSummary(c: Context): Promise { uniqueProvidersCountResult, totalATPsResult, totalDepositsCountResult, // ALL deposits (source of truth for total stakes) - currentAPR + currentAPR, + // Per-attester latest event timestamps. Used to compute the + // "currently exiting" set (latest withdrawInitiated > latest + // withdrawFinalized) without scanning the full event tables. + latestInitiatesByAttester, + latestFinalizesByAttester, + // Authoritative active count from chain. `getActiveAttesterCount` + // returns the VALIDATING set on the canonical rollup — excludes + // ZOMBIE and EXITING by protocol design. + activeAttesterCount ] = await Promise.all([ getActivationThreshold(rollupAddress, client), db.select({ count: count() }).from(stakedWithProvider), @@ -56,7 +66,22 @@ export async function handleStakingSummary(c: Context): Promise { db.select({ count: count() }).from(provider), db.select({ count: count() }).from(atpPosition), db.select({ count: count() }).from(deposit), - calculateAPR(rollupAddress, client) + calculateAPR(rollupAddress, client), + db + .select({ + attesterAddress: withdrawInitiated.attesterAddress, + maxTimestamp: sql`MAX(${withdrawInitiated.timestamp})`.as('max_ts'), + }) + .from(withdrawInitiated) + .groupBy(withdrawInitiated.attesterAddress), + db + .select({ + attesterAddress: withdrawFinalized.attesterAddress, + maxTimestamp: sql`MAX(${withdrawFinalized.timestamp})`.as('max_ts'), + }) + .from(withdrawFinalized) + .groupBy(withdrawFinalized.attesterAddress), + getActiveAttesterCount(rollupAddress, client) ]); const atpDelegationsCount = Number(delegationsCountResult[0].count); @@ -105,12 +130,58 @@ export async function handleStakingSummary(c: Context): Promise { const totalStakes = totalDepositsCount - withdrawnCount; const totalValueLocked = BigInt(activationThreshold) * BigInt(totalStakes); + // Split `totalStakes` into ACTIVE / EXITING / ZOMBIE buckets so the + // dashboard can show the productive-stake number prominently and + // de-emphasise the rest. + // + // ACTIVE comes straight from the chain (VALIDATING on canonical + // rollup). Authoritative. + // + // EXITING is derived from the indexer: attesters whose latest + // `withdrawInitiated` timestamp exceeds their latest + // `withdrawFinalized` (or who have an initiate but no finalize at + // all). Same logic the canonical-rollup-updated handler uses for + // pinning effectiveRollup. + // + // ZOMBIE is derived by subtraction: total registered - active - + // exiting. We don't independently track zombie state (would require + // per-attester slash accounting + ejection threshold), and accept + // that this includes any small drift between the indexer's view of + // "still registered" and the chain's view (e.g., attesters who + // deposited on a now-legacy rollup, never migrated, and aren't on + // canonical's active set). + const latestFinalizeByAttester = new Map(); + for (const r of latestFinalizesByAttester) { + latestFinalizeByAttester.set(r.attesterAddress, r.maxTimestamp); + } + let exitingCount = 0; + for (const r of latestInitiatesByAttester) { + const finalizeTs = latestFinalizeByAttester.get(r.attesterAddress); + if (finalizeTs === undefined || r.maxTimestamp > finalizeTs) { + exitingCount++; + } + } + const activeCount = Number(activeAttesterCount); + // Clamp to 0 — small drift between chain and indexer views can + // produce a transient negative, but the user-visible count must be + // a non-negative integer. + const zombieCount = Math.max(0, totalStakes - activeCount - exitingCount); + + const activeValueLocked = BigInt(activationThreshold) * BigInt(activeCount); + const response: StakingSummaryResponse = { totalValueLocked: totalValueLocked.toString(), totalStakers: totalStakes, currentAPR: currentAPR, + // Active-only TVL — the primary display number on the dashboard. + // Sized so productive stake is highlighted; UI shows exiting + + // zombie context as a smaller subline. + activeValueLocked: activeValueLocked.toString(), stats: { totalStakes: totalStakes, + activeStakes: activeCount, + exitingStakes: exitingCount, + zombieStakes: zombieCount, delegatedStakes: totalDelegationsCount, atpDelegatedStakes: atpDelegationsCount, erc20DelegatedStakes: erc20DelegationsCount, diff --git a/atp-indexer/src/api/types/staking.types.ts b/atp-indexer/src/api/types/staking.types.ts index e70691914..f9d78f411 100644 --- a/atp-indexer/src/api/types/staking.types.ts +++ b/atp-indexer/src/api/types/staking.types.ts @@ -6,6 +6,23 @@ import type { StakeStatus } from './atp.types'; export interface StakingStats { totalStakes: number; + /** + * Currently VALIDATING attesters on the canonical rollup. Read from + * chain (`getActiveAttesterCount`) — authoritative for the dashboard's + * "active sequencer count" headline. + */ + activeStakes: number; + /** + * Attesters whose latest withdraw event is an `initiateWithdraw` (not + * yet finalized). Derived from indexer event tables. + */ + exitingStakes: number; + /** + * Registered attesters that aren't active or exiting — typically + * slashed below ejection threshold. Derived by subtraction + * (`totalStakes - activeStakes - exitingStakes`), clamped to 0. + */ + zombieStakes: number; delegatedStakes: number; atpDelegatedStakes: number; erc20DelegatedStakes: number; @@ -18,7 +35,15 @@ export interface StakingStats { } export interface StakingSummaryResponse { + /** Value locked across the full registered set (active + exiting + zombie). */ totalValueLocked: string; + /** + * Value locked by currently-active sequencers only. The dashboard + * displays this prominently as "Total Value Staked"; the leftover + * (totalValueLocked - activeValueLocked) is the value held by + * exiting/zombie attesters. + */ + activeValueLocked: string; totalStakers: number; currentAPR: number; stats: StakingStats; diff --git a/staking-dashboard/src/components/HeroSection/HeroSection.tsx b/staking-dashboard/src/components/HeroSection/HeroSection.tsx index a4af249aa..bf7c26aca 100644 --- a/staking-dashboard/src/components/HeroSection/HeroSection.tsx +++ b/staking-dashboard/src/components/HeroSection/HeroSection.tsx @@ -25,39 +25,77 @@ export const HeroSection = () => { return () => window.removeEventListener("scroll", handleScroll); }, []); - // Format the data from API - const totalValueLocked = stakingData?.totalValueLocked - ? formatTokenAmount(BigInt(stakingData.totalValueLocked), decimals, symbol) + // Format the data from API. We show active stake prominently; the + // exiting/zombie portion is surfaced as a smaller subline so the + // primary number reflects productive sequencers only. + const totalStakedFallback = stakingData?.totalValueLocked + ? BigInt(stakingData.totalValueLocked) + : undefined; + const activeStakedRaw = + stakingData?.activeValueLocked !== undefined + ? BigInt(stakingData.activeValueLocked) + : totalStakedFallback; + const totalValueStaked = activeStakedRaw !== undefined + ? formatTokenAmount(activeStakedRaw, decimals, symbol) : "---"; - const totalStakers = stakingData?.totalStakers - ? new Intl.NumberFormat("en-US").format(stakingData.totalStakers) + const activeStakesCount = + stakingData?.stats.activeStakes ?? stakingData?.totalStakers; + const exitingStakesCount = stakingData?.stats.exitingStakes ?? 0; + const zombieStakesCount = stakingData?.stats.zombieStakes ?? 0; + + const activeSequencers = activeStakesCount !== undefined + ? new Intl.NumberFormat("en-US").format(activeStakesCount) : "---"; + // Subline shown beneath the main numbers when there are any non-active + // sequencers. Format: "+12 exiting · +4 zombie" — only the non-zero + // buckets render. + const buildSequencerSubline = () => { + const parts: string[] = []; + if (exitingStakesCount > 0) parts.push(`+${exitingStakesCount} exiting`); + if (zombieStakesCount > 0) parts.push(`+${zombieStakesCount} zombie`); + return parts.length > 0 ? parts.join(" · ") : undefined; + }; + + const buildStakeSubline = () => { + if (!stakingData) return undefined; + const totalVL = totalStakedFallback; + const activeVL = activeStakedRaw; + if (totalVL === undefined || activeVL === undefined) return undefined; + const inactiveValue = totalVL - activeVL; + if (inactiveValue <= 0n) return undefined; + return `${formatTokenAmount(inactiveValue, decimals, symbol)} exiting or zombie`; + }; + const currentAPR = stakingData?.currentAPR ? `${stakingData.currentAPR.toFixed(1)}%` : "---%"; const stats = [ { - title: "Total Value Locked", - value: isLoading ? "..." : totalValueLocked, - description: "Total value currently staked in the protocol", + title: "Total Value Staked", + value: isLoading ? "..." : totalValueStaked, + description: "Locked in currently-active sequencers", + subline: isLoading ? undefined : buildStakeSubline(), }, { title: "Estimated APR", value: isLoading ? "..." : currentAPR, description: "Adjusted for queued attesters", + subline: undefined, }, { - title: "Total Number of Sequencers", - value: isLoading ? "..." : totalStakers, - description: "Active, or exiting", + title: "Active Sequencers", + value: isLoading ? "..." : activeSequencers, + description: "Currently validating on canonical rollup", + subline: isLoading ? undefined : buildSequencerSubline(), }, { title: "Minimum Stake Required", value: isLoadingThreshold ? "..." : formattedThreshold, description: "Per stake position", + subline: undefined, }, ]; @@ -110,12 +148,12 @@ export const HeroSection = () => { { > {stat.value} + {stat.subline && ( +
+ {stat.subline} +
+ )}
{stat.description}
diff --git a/staking-dashboard/src/components/StakingSummaryCard/StakingSummaryCard.tsx b/staking-dashboard/src/components/StakingSummaryCard/StakingSummaryCard.tsx index 2800a8911..190e84e90 100644 --- a/staking-dashboard/src/components/StakingSummaryCard/StakingSummaryCard.tsx +++ b/staking-dashboard/src/components/StakingSummaryCard/StakingSummaryCard.tsx @@ -24,11 +24,14 @@ const formatAPR = (apr: number): string => { const MetricCard = ({ label, value, + subline, isLoading, color = 'parchment' }: { label: string value: string + /** Small text below the main value (e.g. "+12 exiting · +4 zombie"). Hidden when undefined. */ + subline?: string isLoading: boolean color?: 'parchment' | 'chartreuse' | 'aqua' | 'orchid' }) => { @@ -40,9 +43,16 @@ const MetricCard = ({ {isLoading ? (
) : ( -
- {value} -
+ <> +
+ {value} +
+ {subline && ( +
+ {subline} +
+ )} + )}
) @@ -56,15 +66,50 @@ export const StakingSummaryCard = () => { const { data, isLoading, error, refetch } = useStakingSummary() const { symbol, decimals } = useStakingAssetTokenDetails() - // Format values using formatTokenAmount - const totalValueLocked = data?.totalValueLocked - ? formatTokenAmount(BigInt(data.totalValueLocked), decimals, symbol) + // Format values using formatTokenAmount. We headline the + // currently-active stake (`activeValueLocked`) rather than the + // historic "all locked" total — productive sequencer stake is what + // operators actually care about. The inactive remainder shows up as a + // small subline. + const totalLockedBig = data?.totalValueLocked ? BigInt(data.totalValueLocked) : undefined + const activeLockedBig = data?.activeValueLocked !== undefined + ? BigInt(data.activeValueLocked) + : totalLockedBig + const totalValueStaked = activeLockedBig !== undefined + ? formatTokenAmount(activeLockedBig, decimals, symbol) : '0' const activationThreshold = data?.stats.activationThreshold ? formatTokenAmount(BigInt(data.stats.activationThreshold), decimals, symbol) : '0' + // Headline sequencer count is `activeStakes` (chain-authoritative). + // Falls back to `totalStakers` for back-compat with older indexer + // builds that haven't shipped the field yet. + const activeStakesCount = data?.stats.activeStakes ?? data?.totalStakers ?? 0 + const exitingStakesCount = data?.stats.exitingStakes ?? 0 + const zombieStakesCount = data?.stats.zombieStakes ?? 0 + + // Build the "+X exiting · +Y zombie" subline. Only the non-zero + // buckets render so the line stays compact in steady state. + const sequencerSubline = (() => { + const parts: string[] = [] + if (exitingStakesCount > 0) parts.push(`+${formatNumber(exitingStakesCount)} exiting`) + if (zombieStakesCount > 0) parts.push(`+${formatNumber(zombieStakesCount)} zombie`) + return parts.length > 0 ? parts.join(' · ') : undefined + })() + + // For the TVL subline we show the token amount held by inactive + // sequencers. Approximate (zombies are below activation threshold, + // but we use the API's reported delta so this matches whatever the + // indexer's accounting says). + const stakeSubline = (() => { + if (totalLockedBig === undefined || activeLockedBig === undefined) return undefined + const inactive = totalLockedBig - activeLockedBig + if (inactive <= 0n) return undefined + return `${formatTokenAmount(inactive, decimals, symbol)} exiting or zombie` + })() + // Error state if (error && !data) { return ( @@ -93,14 +138,16 @@ export const StakingSummaryCard = () => { {/* Main Metrics Grid */}
diff --git a/staking-dashboard/src/hooks/staking/useStakingSummary.ts b/staking-dashboard/src/hooks/staking/useStakingSummary.ts index 4d1c47e81..d5cf0c206 100644 --- a/staking-dashboard/src/hooks/staking/useStakingSummary.ts +++ b/staking-dashboard/src/hooks/staking/useStakingSummary.ts @@ -3,6 +3,12 @@ import { config } from '@/config' export interface StakingStats { totalStakes: number + /** Currently-validating attesters (authoritative from chain). */ + activeStakes?: number + /** Attesters mid-exit (initiate without finalize). Derived indexer-side. */ + exitingStakes?: number + /** Registered minus active minus exiting (typically slashed-below-ejection). */ + zombieStakes?: number delegatedStakes: number directStakes: number activeProviders: number @@ -13,6 +19,8 @@ export interface StakingStats { export interface StakingSummaryResponse { totalValueLocked: string + /** Value locked by currently-active sequencers only. Optional for back-compat. */ + activeValueLocked?: string totalStakers: number currentAPR: number stats: StakingStats