Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions atp-indexer/src/api/handlers/staking/summary.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +12,7 @@ import {
failedDeposit,
provider,
atpPosition,
withdrawInitiated,
withdrawFinalized,
deposit
} from 'ponder:schema';
Expand Down Expand Up @@ -45,7 +46,16 @@ export async function handleStakingSummary(c: Context): Promise<Response> {
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),
Expand All @@ -56,7 +66,22 @@ export async function handleStakingSummary(c: Context): Promise<Response> {
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<bigint>`MAX(${withdrawInitiated.timestamp})`.as('max_ts'),
})
.from(withdrawInitiated)
.groupBy(withdrawInitiated.attesterAddress),
db
.select({
attesterAddress: withdrawFinalized.attesterAddress,
maxTimestamp: sql<bigint>`MAX(${withdrawFinalized.timestamp})`.as('max_ts'),
})
.from(withdrawFinalized)
.groupBy(withdrawFinalized.attesterAddress),
getActiveAttesterCount(rollupAddress, client)
]);

const atpDelegationsCount = Number(delegationsCountResult[0].count);
Expand Down Expand Up @@ -105,12 +130,58 @@ export async function handleStakingSummary(c: Context): Promise<Response> {
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<string, bigint>();
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,
Expand Down
25 changes: 25 additions & 0 deletions atp-indexer/src/api/types/staking.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
73 changes: 58 additions & 15 deletions staking-dashboard/src/components/HeroSection/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

Expand Down Expand Up @@ -110,12 +148,12 @@ export const HeroSection = () => {
</div>
<TooltipIcon
content={
stat.title === "Total Value Locked"
? "Total value of all tokens currently staked in the protocol"
stat.title === "Total Value Staked"
? "Value locked by currently-active sequencers only. Stake from exiting and zombie sequencers is excluded from this number — see the subline for the leftover."
: stat.title === "Estimated APR"
? "Estimated annual return based on current rewards and total sequencers (including queued). Actual returns may vary."
: stat.title === "Total Number of Sequencers"
? "Does not include queued attesters. Includes sequencers who initiated exits but have not yet finalized."
: stat.title === "Active Sequencers"
? "Currently-validating sequencers on the canonical rollup. Excludes queued, exiting, and zombie (slashed-below-threshold) sequencers — see the subline for those counts."
: stat.title === "Minimum Stake Required"
? "The minimum amount of tokens required to create a single stake position"
: "Total tokens distributed as rewards to all sequencers."
Expand All @@ -129,6 +167,11 @@ export const HeroSection = () => {
>
{stat.value}
</div>
{stat.subline && (
<div className="font-mono text-[10px] sm:text-xs text-parchment/50 mb-1">
{stat.subline}
</div>
)}
<div className="font-md-thermochrome text-xs sm:text-sm lg:text-xs xl:text-sm text-aqua">
{stat.description}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}) => {
Expand All @@ -40,9 +43,16 @@ const MetricCard = ({
{isLoading ? (
<div className="h-8 bg-parchment/10 animate-pulse rounded" />
) : (
<div className={`font-arizona-serif text-2xl sm:text-3xl font-medium text-${color}`}>
{value}
</div>
<>
<div className={`font-arizona-serif text-2xl sm:text-3xl font-medium text-${color}`}>
{value}
</div>
{subline && (
<div className="font-mono text-[10px] sm:text-xs text-parchment/50 mt-1">
{subline}
</div>
)}
</>
)}
</div>
)
Expand All @@ -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 (
Expand Down Expand Up @@ -93,14 +138,16 @@ export const StakingSummaryCard = () => {
{/* Main Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Total Value Locked"
value={totalValueLocked}
label="Total Value Staked"
value={totalValueStaked}
subline={stakeSubline}
isLoading={isLoading}
color="chartreuse"
/>
<MetricCard
label="Total Stakes"
value={data ? formatNumber(data.totalStakers) : '0'}
label="Active Sequencers"
value={formatNumber(activeStakesCount)}
subline={sequencerSubline}
isLoading={isLoading}
color="aqua"
/>
Expand Down
Loading
Loading