diff --git a/atp-indexer/src/abis/rollup.abi.ts b/atp-indexer/src/abis/rollup.abi.ts index 2d6c874f2..7bfe20880 100644 --- a/atp-indexer/src/abis/rollup.abi.ts +++ b/atp-indexer/src/abis/rollup.abi.ts @@ -13,6 +13,17 @@ export const ROLLUP_FUNCTIONS = [ stateMutability: 'view', type: 'function', }, + { + // Sequencers whose effective balance falls below this are classified + // ZOMBIE by the protocol: still registered (tokens locked) but not + // validating. Used by the dashboard's TVL / per-provider math to + // identify the threshold at which slashed stakes leave the active set. + inputs: [], + name: 'getLocalEjectionThreshold', + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, { inputs: [], name: 'getRewardConfig', diff --git a/atp-indexer/src/api/handlers/provider/details.ts b/atp-indexer/src/api/handlers/provider/details.ts index e73d79aaa..e91d44452 100644 --- a/atp-indexer/src/api/handlers/provider/details.ts +++ b/atp-indexer/src/api/handlers/provider/details.ts @@ -5,9 +5,10 @@ import { checksumAddress, normalizeAddress } from '../../../utils/address'; import { getProviderMetadata } from '../../../utils/provider-metadata'; import type { ProviderDetailsResponse } from '../../types/provider.types'; import { fetchFailedDeposits, filterValidStakes } from '../../../utils/failed-deposits'; -import { getActivationThreshold } from '../../../utils/rollup'; +import { getActivationThreshold, getLocalEjectionThreshold } from '../../../utils/rollup'; import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; +import { buildAttesterStateLookup } from '../../utils/attester-state'; import { provider, stakedWithProvider, @@ -28,8 +29,9 @@ export async function handleProviderDetails(c: Context): Promise { const client = getPublicClient(); const rollupAddress = await getCanonicalRollupAddress(client); - const [activationThreshold, providerData, allAtpDelegationsCount, allErc20DelegationsCount, allDirectStakesCount, allFailedDepositCount] = await Promise.all([ + const [activationThreshold, ejectionThreshold, providerData, allAtpDelegationsCount, allErc20DelegationsCount, allDirectStakesCount, allFailedDepositCount] = await Promise.all([ getActivationThreshold(rollupAddress, client), + getLocalEjectionThreshold(rollupAddress, client), db.select().from(provider).where(eq(provider.providerIdentifier, id)).limit(1), db.select({ count: count() }).from(stakedWithProvider), db.select({ count: count() }).from(erc20StakedWithProvider), @@ -110,8 +112,20 @@ export async function handleProviderDetails(c: Context): Promise { ]; const validAllStakes = filterValidStakes(allStakes, failedDepositMap); - // Extract only the valid delegations (ignore direct stakes, they were just for FIFO) - const validDelegations = validAllStakes.filter(s => s._type === 'delegation'); + // Build per-attester status + effective-balance lookup. Used to + // filter the provider's headline numbers to ACTIVE sequencers only + // (exiting / zombie sequencers don't validate) and to sum by + // effective balance instead of deposit-time amount. + const attesterState = await buildAttesterStateLookup({ + db, + activationThreshold: BigInt(activationThreshold), + ejectionThreshold: BigInt(ejectionThreshold), + }); + + // Extract only the valid + ACTIVE delegations (ignore direct stakes — they were just for FIFO) + const validDelegations = validAllStakes + .filter(s => s._type === 'delegation') + .filter(s => attesterState(normalizeAddress(s.attesterAddress)).status === 'ACTIVE'); // Calculate network total staked // All attempted stakes + all delegations (ATP + ERC20) - all failed deposits @@ -120,13 +134,39 @@ export async function handleProviderDetails(c: Context): Promise { const metadata = getProviderMetadata(providerRecord.providerIdentifier); - const providerSelfStakeCount = metadata?.providerSelfStake?.length || 0 - - // Calculate provider total staked amount + total provider self stake amount - const totalProviderSelfStakes = BigInt(providerSelfStakeCount) * BigInt(activationThreshold) - const totalDelegations = validDelegations.reduce((sum, stake) => { - return sum + BigInt(stake.stakedAmount); - }, 0n); + // Self-stake roster: filter to ACTIVE attesters only and sum by + // effective balance. Self-stake addresses are inherently unique + // per provider so a single reduce is correct here. + const declaredSelfStake = metadata?.providerSelfStake ?? []; + const activeSelfStake = declaredSelfStake.filter( + addr => attesterState(normalizeAddress(addr)).status === 'ACTIVE', + ); + const providerSelfStakeCount = activeSelfStake.length; + const totalProviderSelfStakes = activeSelfStake.reduce( + (sum, addr) => sum + attesterState(normalizeAddress(addr)).effectiveBalance, + 0n, + ); + + // Sum delegated stake using a per-attester slash dedupe: each row + // contributes its own `stakedAmount` (so a multi-deposit attester + // is sized correctly), but the slash deduction is applied once + // per UNIQUE attester. Without the dedupe, an attester appearing + // in multiple delegation rows would have their slashed amount + // subtracted twice — understating the headline. + let totalDelegations = 0n; + { + let nominal = 0n; + const seenAttesters = new Set(); + let totalSlashes = 0n; + for (const s of validDelegations) { + nominal += BigInt(s.stakedAmount); + const normalized = normalizeAddress(s.attesterAddress); + if (seenAttesters.has(normalized)) continue; + seenAttesters.add(normalized); + totalSlashes += attesterState(normalized).totalSlashed; + } + totalDelegations = nominal > totalSlashes ? nominal - totalSlashes : 0n; + } const totalStaked = totalDelegations + totalProviderSelfStakes const response: ProviderDetailsResponse = { diff --git a/atp-indexer/src/api/handlers/provider/list.ts b/atp-indexer/src/api/handlers/provider/list.ts index 750325a11..8d0c6616e 100644 --- a/atp-indexer/src/api/handlers/provider/list.ts +++ b/atp-indexer/src/api/handlers/provider/list.ts @@ -5,9 +5,10 @@ import { checksumAddress, normalizeAddress } from '../../../utils/address'; import { getAllProviderMetadata } from '../../../utils/provider-metadata'; import type { ProviderListResponse } from '../../types/provider.types'; import { fetchFailedDeposits, markStakesWithFailedDeposits } from '../../../utils/failed-deposits'; -import { getActivationThreshold } from '../../../utils/rollup'; +import { getActivationThreshold, getLocalEjectionThreshold } from '../../../utils/rollup'; import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; +import { buildAttesterStateLookup } from '../../utils/attester-state'; import { provider, stakedWithProvider, @@ -28,8 +29,16 @@ export async function handleProviderList(c: Context): Promise { const providerIds = Array.from(metadata.keys()); const rollupAddress = await getCanonicalRollupAddress(client); - const [activationThreshold, dbProviders, atpDelegations, erc20Delegations, allDirectStakes] = await Promise.all([ + const [ + activationThreshold, + ejectionThreshold, + dbProviders, + atpDelegations, + erc20Delegations, + allDirectStakes, + ] = await Promise.all([ getActivationThreshold(rollupAddress, client), + getLocalEjectionThreshold(rollupAddress, client), db.select().from(provider).where(inArray(provider.providerIdentifier, providerIds)), db.select({ providerIdentifier: stakedWithProvider.providerIdentifier, @@ -59,6 +68,18 @@ export async function handleProviderList(c: Context): Promise { }).from(staked) ]); + // Build a per-attester status + effective-balance lookup. Used + // to filter the provider list (and "Not Associated" bucket) down + // to ACTIVE attesters only — exiting and zombie sequencers + // shouldn't inflate a provider's headline numbers — and to use + // effective balance (deposit - slashed) instead of deposit-time + // amount in the totals. + const attesterState = await buildAttesterStateLookup({ + db, + activationThreshold: BigInt(activationThreshold), + ejectionThreshold: BigInt(ejectionThreshold), + }); + // Combine ATP-based and ERC20-based delegations const allDelegations = [...atpDelegations, ...erc20Delegations]; @@ -84,15 +105,43 @@ export async function handleProviderList(c: Context): Promise { ]; const markedAllStakes = markStakesWithFailedDeposits(allStakes, failedDepositMap); - // Filter out failed deposits and unstaked (withdrawn) stakes - const isActiveStake = (s: { hasFailedDeposit: boolean; status?: string }) => - !s.hasFailedDeposit && s.status !== 'UNSTAKED'; + // Filter out failed deposits, unstaked, exiting, and zombie stakes. + // Provider headline numbers should reflect *productive* sequencers + // only — slashed-into-zombie or mid-exit sequencers no longer + // contribute validation work, so counting them is misleading. + const isActiveStake = (s: { hasFailedDeposit: boolean; status?: string; attesterAddress: string }) => { + if (s.hasFailedDeposit || s.status === 'UNSTAKED') return false; + // Per-attester lookup; non-existent attesters default to ACTIVE + // (no slash, no exit) which is the right answer for fresh stakes. + return attesterState(normalizeAddress(s.attesterAddress)).status === 'ACTIVE'; + }; const validAllStakes = markedAllStakes.filter(isActiveStake); // Separate back into delegations and direct stakes const validDelegations = validAllStakes.filter(s => s._type === 'delegation'); const validDirectStakes = validAllStakes.filter(s => s._type === 'direct'); + // Sum stake using a per-attester model. Each row contributes its + // own deposit amount (so a multi-deposit attester is correctly + // sized at `count × activationThreshold` nominal), but the slash + // deduction applies once per UNIQUE attester (slashing is per + // attester globally, not per delegation row). Without the dedupe, + // an attester with two stake rows + a slash of X would have X + // subtracted twice → headline understates real on-chain balance. + const sumEffectiveBalance = (stakes: { attesterAddress: string; stakedAmount: bigint | string }[]): bigint => { + let nominal = 0n; + const seenAttesters = new Set(); + let totalSlashes = 0n; + for (const s of stakes) { + nominal += BigInt(s.stakedAmount); + const normalized = normalizeAddress(s.attesterAddress); + if (seenAttesters.has(normalized)) continue; + seenAttesters.add(normalized); + totalSlashes += attesterState(normalized).totalSlashed; + } + return nominal > totalSlashes ? nominal - totalSlashes : 0n; + }; + // Group valid delegations by provider const stakesByProvider = new Map(); const unassociatedStakesWithProvider: typeof validDelegations = []; @@ -108,12 +157,8 @@ export async function handleProviderList(c: Context): Promise { } } - const totalDirectStakeAmount = validDirectStakes.reduce((sum, stake) => { - return sum + BigInt(stake.stakedAmount); - }, 0n) - const totalProviderStakeAmount = validDelegations.reduce((sum, stake) => { - return sum + BigInt(stake.stakedAmount); - }, 0n) + const totalDirectStakeAmount = sumEffectiveBalance(validDirectStakes); + const totalProviderStakeAmount = sumEffectiveBalance(validDelegations); // Calculate total staked across entire network (from valid stakes only) const networkTotalStaked = totalProviderStakeAmount + totalDirectStakeAmount @@ -127,16 +172,25 @@ export async function handleProviderList(c: Context): Promise { const providerStakes = stakesByProvider.get(provider.providerIdentifier) || []; // Get self-stake count - // This is to accumulate self stakes into provider with metadata - const selfStakeCount = meta?.providerSelfStake?.length || 0; - const selfStakeAmount = BigInt(selfStakeCount) * BigInt(activationThreshold); + // This is to accumulate self stakes into provider with metadata. + // Self-stake entries are filtered to ACTIVE attesters and summed + // by effective balance, same as delegated stakes — keeps the + // numbers honest if a self-staked sequencer gets slashed or + // initiates an exit. + const declaredSelfStake = meta?.providerSelfStake ?? []; + const activeSelfStake = declaredSelfStake.filter( + addr => attesterState(normalizeAddress(addr)).status === 'ACTIVE', + ); + const selfStakeCount = activeSelfStake.length; + const selfStakeAmount = activeSelfStake.reduce( + (sum, addr) => sum + attesterState(normalizeAddress(addr)).effectiveBalance, + 0n, + ); // Add provider self stake count to provider stakes const delegationsWithSelfStake = providerStakes.length + selfStakeCount; - const providerTotalStaked = providerStakes.reduce((sum, stake) => { - return sum + BigInt(stake.stakedAmount); - }, 0n) + selfStakeAmount; + const providerTotalStaked = sumEffectiveBalance(providerStakes) + selfStakeAmount; totalProviderSelfStakeAmount += selfStakeAmount totalProviderSelfStakeCount += selfStakeCount @@ -167,15 +221,19 @@ export async function handleProviderList(c: Context): Promise { let notAssociatedStake = undefined; if (totalUnassociatedCount > 0) { - const unassociatedTotalStakedWithProvider = unassociatedStakesWithProvider.reduce((sum, stake) => { - return sum + BigInt(stake.stakedAmount); - }, 0n) + const unassociatedTotalStakedWithProvider = sumEffectiveBalance(unassociatedStakesWithProvider); - const unassociatedTotalStaked = unassociatedTotalStakedWithProvider + totalDirectStakeAmount - totalProviderSelfStakeAmount + // Subtract self-staked direct stakes (already credited to a + // metadata-registered provider above) so they don't double-count + // in the "Not Associated" bucket. Both sides of the subtraction + // now use effective balance, so the math stays consistent. + const unassociatedTotalStaked = + unassociatedTotalStakedWithProvider + totalDirectStakeAmount - totalProviderSelfStakeAmount; + const clampedUnassociatedTotal = unassociatedTotalStaked > 0n ? unassociatedTotalStaked : 0n; notAssociatedStake = { - delegators: totalUnassociatedCount, - totalStaked: unassociatedTotalStaked.toString() + delegators: Math.max(0, totalUnassociatedCount), + totalStaked: clampedUnassociatedTotal.toString() }; } diff --git a/atp-indexer/src/api/handlers/staking/summary.ts b/atp-indexer/src/api/handlers/staking/summary.ts index 6a95068b6..f3f95371c 100644 --- a/atp-indexer/src/api/handlers/staking/summary.ts +++ b/atp-indexer/src/api/handlers/staking/summary.ts @@ -1,9 +1,10 @@ import type { Context } from 'hono'; import { db } from 'ponder:api'; import { count, sql } from 'drizzle-orm'; -import { getActivationThreshold, calculateAPR, getActiveAttesterCount } from '../../../utils/rollup'; +import { getActivationThreshold, getLocalEjectionThreshold, calculateAPR, getActiveAttesterCount } from '../../../utils/rollup'; import { getCanonicalRollupAddress } from '../../utils/canonical-rollup'; import { getPublicClient } from '../../../utils/viem-client'; +import { classifyAttesterStatus } from '../../utils/attester-state'; import type { StakingSummaryResponse } from '../../types/staking.types'; import { stakedWithProvider, @@ -12,6 +13,7 @@ import { failedDeposit, provider, atpPosition, + slashed, withdrawInitiated, withdrawFinalized, deposit @@ -38,6 +40,7 @@ export async function handleStakingSummary(c: Context): Promise { // while other tables help categorize the source (ATP vs ERC20, direct vs provider). const [ activationThreshold, + ejectionThreshold, delegationsCountResult, // ATP provider delegations erc20DelegationsCountResult, // ERC20 provider delegations directStakesCountResult, // ATP direct stakes @@ -48,16 +51,24 @@ export async function handleStakingSummary(c: Context): Promise { totalDepositsCountResult, // ALL deposits (source of truth for total stakes) currentAPR, // Per-attester latest event timestamps. Used to compute the - // "currently exiting" set (latest withdrawInitiated > latest - // withdrawFinalized) without scanning the full event tables. + // EXITING bucket (latest initiate vs latest finalize) and — for + // the slashed-attester apportionment loop below — to classify + // each slashed attester via the shared `classifyAttesterStatus` + // helper. + latestDepositsByAttester, latestInitiatesByAttester, latestFinalizesByAttester, + // Per-attester slash totals. Used to deduct slashed amounts from + // headline TVL (so the dashboard reflects what's actually still in + // the contract) and to classify zombies (slashed-below-ejection). + slashSumsByAttester, // 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), + getLocalEjectionThreshold(rollupAddress, client), db.select({ count: count() }).from(stakedWithProvider), db.select({ count: count() }).from(erc20StakedWithProvider), db.select({ count: count() }).from(staked), @@ -67,6 +78,13 @@ export async function handleStakingSummary(c: Context): Promise { db.select({ count: count() }).from(atpPosition), db.select({ count: count() }).from(deposit), calculateAPR(rollupAddress, client), + db + .select({ + attesterAddress: deposit.attesterAddress, + maxTimestamp: sql`MAX(${deposit.timestamp})`.as('max_deposit_ts'), + }) + .from(deposit) + .groupBy(deposit.attesterAddress), db .select({ attesterAddress: withdrawInitiated.attesterAddress, @@ -81,6 +99,13 @@ export async function handleStakingSummary(c: Context): Promise { }) .from(withdrawFinalized) .groupBy(withdrawFinalized.attesterAddress), + db + .select({ + attesterAddress: slashed.attesterAddress, + totalAmount: sql`SUM(${slashed.amount})`.as('total_slashed'), + }) + .from(slashed) + .groupBy(slashed.attesterAddress), getActiveAttesterCount(rollupAddress, client) ]); @@ -128,32 +153,22 @@ export async function handleStakingSummary(c: Context): Promise { // NOTE: Do NOT subtract failedDepositsLength here - failed deposits are never // added to the deposit table (they trigger a separate FailedDeposit event) 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). + // Per-attester latest-event maps. Used both to compute the EXITING + // bucket and to apportion slashed amounts across status buckets. + const latestDepositByAttester = new Map(); + for (const r of latestDepositsByAttester) { + latestDepositByAttester.set(r.attesterAddress, r.maxTimestamp); + } + const latestInitiateByAttester = new Map(); + for (const r of latestInitiatesByAttester) { + latestInitiateByAttester.set(r.attesterAddress, r.maxTimestamp); + } 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); @@ -161,13 +176,79 @@ export async function handleStakingSummary(c: Context): Promise { 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. + + // Clamp `activeCount` to the indexer's view of the registered set. + // Chain (`getActiveAttesterCount`) and indexer (`totalStakes`) can + // drift by a block or two; if the chain reports `activeCount > + // totalStakes - exitingCount`, the dashboard's `totalVL − activeVL` + // subline math would go negative. Clamp keeps the invariant + // `0 ≤ activeCount ≤ totalStakes - exitingCount` so downstream + // values stay non-negative. + const rawActiveCount = Number(activeAttesterCount); + const activeCountCeiling = Math.max(0, totalStakes - exitingCount); + const activeCount = Math.min(rawActiveCount, activeCountCeiling); const zombieCount = Math.max(0, totalStakes - activeCount - exitingCount); - const activeValueLocked = BigInt(activationThreshold) * BigInt(activeCount); + // Slash apportionment by status. We need this to deduct slashed + // amounts from headline TVL — the deposit-time `stakedAmount` and + // the `activationThreshold × count` shortcut both overstate when + // any attester has been slashed. Classification uses the shared + // `classifyAttesterStatus` helper so the priority logic stays in + // one place. + const activationThresholdBig = BigInt(activationThreshold); + const ejectionThresholdBig = BigInt(ejectionThreshold); + const zombieSlashCutoff = activationThresholdBig > ejectionThresholdBig + ? activationThresholdBig - ejectionThresholdBig + : 0n; + + let slashedActive = 0n; + let slashedExiting = 0n; + let slashedZombie = 0n; + + for (const r of slashSumsByAttester) { + // An attester with a Slashed event MUST have a prior Deposit + // (the protocol can't slash a non-existent attester) — so + // `hasDeposit: true` is safe. `latestDeposit` may still be + // missing in pathological indexer states (event re-ordering); + // the classifier treats that the same as no-finalize-after-no- + // deposit (won't fall into EXITED) which is the right answer. + const status = classifyAttesterStatus({ + hasDeposit: true, + latestDeposit: latestDepositByAttester.get(r.attesterAddress), + latestInitiate: latestInitiateByAttester.get(r.attesterAddress), + latestFinalize: latestFinalizeByAttester.get(r.attesterAddress), + totalSlashed: r.totalAmount, + zombieSlashCutoff, + }); + + switch (status) { + case "ACTIVE": + slashedActive += r.totalAmount; + break; + case "EXITING": + slashedExiting += r.totalAmount; + break; + case "ZOMBIE": + slashedZombie += r.totalAmount; + break; + // EXITED / NOT_REGISTERED: tokens are gone (or never there); + // their slashes don't affect on-contract TVL. + } + } + + const slashedRegistered = slashedActive + slashedExiting + slashedZombie; + + // Headline TVL math: start from the nominal value (count × threshold) + // and subtract the slashed amount for that bucket. Clamp at 0 to + // tolerate small drift between chain and indexer (e.g., reward + // accumulation we don't track could leave actual on-chain balance + // slightly above our computed effective balance, but never below + // zero). + const nominalTotal = activationThresholdBig * BigInt(totalStakes); + const totalValueLocked = nominalTotal > slashedRegistered ? nominalTotal - slashedRegistered : 0n; + + const nominalActive = activationThresholdBig * BigInt(activeCount); + const activeValueLocked = nominalActive > slashedActive ? nominalActive - slashedActive : 0n; const response: StakingSummaryResponse = { totalValueLocked: totalValueLocked.toString(), diff --git a/atp-indexer/src/api/utils/attester-state.ts b/atp-indexer/src/api/utils/attester-state.ts new file mode 100644 index 000000000..b283c6503 --- /dev/null +++ b/atp-indexer/src/api/utils/attester-state.ts @@ -0,0 +1,233 @@ +import { sql } from "drizzle-orm"; +import { + deposit, + slashed, + withdrawInitiated, + withdrawFinalized, +} from "ponder:schema"; + +/** + * Per-attester runtime classification, derived from indexer event tables + * + chain-supplied thresholds. The dashboard and indexer APIs use this + * to keep TVL / per-provider totals honest in the face of slashing, + * exits, and re-deposits. + * + * - ACTIVE: registered and able to validate (effective balance ≥ ejection threshold, + * no in-flight exit, no clean exit since last deposit). + * - EXITING: latest `withdrawInitiated` is newer than latest `withdrawFinalized`. + * Exit locks the stake to the rollup where the initiate fired, so the + * protocol won't reclassify them mid-exit even if they get slashed. + * - ZOMBIE: effective balance < ejection threshold; still registered, no + * longer validating. + * - EXITED: latest `withdrawFinalized` is newer than the latest `deposit`. + * The attester finalized and has not re-deposited since. + * - NOT_REGISTERED: address has never been seen in the `deposit` event + * table. Returned when a caller asks about an address that was never + * a sequencer (e.g., a typo'd `providerSelfStake` entry, or an + * address that only exists off-chain). + * + * EXITING wins over ZOMBIE if both apply, matching the protocol's + * priority (an attester in the middle of exiting can't be moved into + * zombie state). + */ +export type AttesterStatus = "ACTIVE" | "EXITING" | "ZOMBIE" | "EXITED" | "NOT_REGISTERED"; + +export interface AttesterState { + status: AttesterStatus; + /** + * activationThreshold − sum(slashed amounts), clamped to 0. EXITED + * and NOT_REGISTERED always return 0 here — even if the math would + * otherwise yield a positive number — because tokens have left the + * contract (EXITED) or were never there (NOT_REGISTERED). Callers + * summing TVL can rely on `effectiveBalance` alone without needing + * to also filter by status. + * + * Approximation: rewards may add to on-chain balance but we don't + * track them here — actual effective balance can be slightly higher. + * Used for "active TVL" math where we'd rather understate than + * overstate. + */ + effectiveBalance: bigint; + /** Sum of all Slashed events for this attester. 0 if never slashed. */ + totalSlashed: bigint; +} + +/** + * Inputs to {@link classifyAttesterStatus}. Kept separate from the + * lookup factory so `summary.ts` (which already has every map + * materialised in-process) can share the exact same classification + * logic without re-running the lookup-builder DB scans. + */ +export interface ClassifierInputs { + /** Whether this attester appears in the `deposit` table at all. */ + hasDeposit: boolean; + /** Latest `deposit` timestamp for this attester. `undefined` if no deposit. */ + latestDeposit: bigint | undefined; + /** Latest `withdrawInitiated` timestamp. */ + latestInitiate: bigint | undefined; + /** Latest `withdrawFinalized` timestamp. */ + latestFinalize: bigint | undefined; + /** Sum of all slashed amounts for this attester. */ + totalSlashed: bigint; + /** + * `activationThreshold − ejectionThreshold` — the slash amount at + * which an attester crosses into ZOMBIE. 0n disables zombie + * classification (e.g., when the ejection-threshold RPC failed). + */ + zombieSlashCutoff: bigint; +} + +/** + * Pure classifier — given the per-attester aggregates, return the + * status. Shared between `buildAttesterStateLookup` (request-scoped + * lookup table) and `summary.ts`'s inline loop (which only needs to + * apportion slashes by status, not query every attester). Keeping + * this in one place means fixes to status priority can't drift. + */ +export function classifyAttesterStatus(inputs: ClassifierInputs): AttesterStatus { + if (!inputs.hasDeposit) return "NOT_REGISTERED"; + + const { latestDeposit, latestInitiate, latestFinalize, totalSlashed, zombieSlashCutoff } = inputs; + + // EXITING: there's an open initiate (newer than the most recent + // finalize, or no finalize at all). Wins over ZOMBIE because the + // protocol pins the exit even if the attester would otherwise + // classify as zombie. Note we compare against `latestFinalize` — + // NOT `latestDeposit` — because an attester can re-initiate during + // a re-deposited lifecycle. + if (latestInitiate !== undefined && (latestFinalize === undefined || latestInitiate > latestFinalize)) { + return "EXITING"; + } + + // EXITED: the latest finalize is *strictly* newer than any deposit. + // The attester finalized cleanly and hasn't re-deposited since. + // If they re-deposit after a clean exit (latestDeposit > latestFinalize), + // we fall through to ACTIVE/ZOMBIE — they're a productive validator + // again. Same-timestamp ties (finalize and deposit in the same block, + // common during a re-deposit that immediately re-enters the queue) + // resolve to ACTIVE rather than EXITED — re-deposit wins, since + // classifying a freshly-restaked attester as EXITED would zero out + // their effective balance. + if (latestFinalize !== undefined && latestDeposit !== undefined && latestFinalize > latestDeposit) { + return "EXITED"; + } + + // ZOMBIE: not exiting/exited, but slashed below the ejection + // threshold. zombieSlashCutoff is 0n in the defensive case where the + // ejection-threshold RPC failed; in that case no one is classified + // ZOMBIE and the dashboard's chain probe handles individual stakes. + if (zombieSlashCutoff > 0n && totalSlashed >= zombieSlashCutoff) { + return "ZOMBIE"; + } + + return "ACTIVE"; +} + +interface BuildInputs { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any; + activationThreshold: bigint; + ejectionThreshold: bigint; +} + +/** + * Pre-aggregates the deposit / slashed / withdrawInitiated / + * withdrawFinalized tables per attester (one SQL `GROUP BY` per table) + * and returns a helper bound over those maps. Call it once per request; + * reuse the helper across every attester lookup. + * + * The returned helper handles attesters with NO slashes / exits cleanly + * (returns ACTIVE with full effectiveBalance), and addresses never seen + * in `deposit` (returns NOT_REGISTERED with zero balance) — callers + * don't need to short-circuit themselves. + */ +export async function buildAttesterStateLookup({ + db, + activationThreshold, + ejectionThreshold, +}: BuildInputs): Promise<(attesterAddress: string) => AttesterState> { + const [depositAggregates, latestInitiates, latestFinalizes, slashSums] = await Promise.all([ + // Both the "ever deposited" set and the per-attester latest deposit + // timestamp come from one query — we need the timestamp to detect + // re-deposit-after-finalize cases (which must classify as ACTIVE, + // not EXITED). + db + .select({ + attesterAddress: deposit.attesterAddress, + maxTimestamp: sql`MAX(${deposit.timestamp})`.as("max_deposit_ts"), + }) + .from(deposit) + .groupBy(deposit.attesterAddress), + 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), + db + .select({ + attesterAddress: slashed.attesterAddress, + totalAmount: sql`SUM(${slashed.amount})`.as("total_slashed"), + }) + .from(slashed) + .groupBy(slashed.attesterAddress), + ]); + + const depositMap = new Map(); + for (const r of depositAggregates) { + depositMap.set(r.attesterAddress, r.maxTimestamp); + } + const initiateMap = new Map(); + for (const r of latestInitiates) { + initiateMap.set(r.attesterAddress, r.maxTimestamp); + } + const finalizeMap = new Map(); + for (const r of latestFinalizes) { + finalizeMap.set(r.attesterAddress, r.maxTimestamp); + } + const slashMap = new Map(); + for (const r of slashSums) { + slashMap.set(r.attesterAddress, r.totalAmount); + } + + const zombieSlashCutoff = activationThreshold > ejectionThreshold + ? activationThreshold - ejectionThreshold + : 0n; + + return function lookup(attesterAddress: string): AttesterState { + const totalSlashed = slashMap.get(attesterAddress) ?? 0n; + const latestDeposit = depositMap.get(attesterAddress); + const latestInitiate = initiateMap.get(attesterAddress); + const latestFinalize = finalizeMap.get(attesterAddress); + + const status = classifyAttesterStatus({ + hasDeposit: latestDeposit !== undefined, + latestDeposit, + latestInitiate, + latestFinalize, + totalSlashed, + zombieSlashCutoff, + }); + + // EXITED and NOT_REGISTERED return 0 effective balance — tokens + // have left the contract or were never there. Saves every caller + // from needing to filter by status before summing. + if (status === "EXITED" || status === "NOT_REGISTERED") { + return { status, effectiveBalance: 0n, totalSlashed }; + } + + const effectiveBalance = activationThreshold > totalSlashed + ? activationThreshold - totalSlashed + : 0n; + + return { status, effectiveBalance, totalSlashed }; + }; +} diff --git a/atp-indexer/src/utils/rollup.ts b/atp-indexer/src/utils/rollup.ts index e891abd5f..312624da5 100644 --- a/atp-indexer/src/utils/rollup.ts +++ b/atp-indexer/src/utils/rollup.ts @@ -57,6 +57,31 @@ export async function getActivationThreshold( } } +/** + * Get local ejection threshold from Rollup contract — the effective- + * balance floor below which an attester is classified ZOMBIE (still + * registered, no longer validating). Returns '0' on RPC failure so + * callers degrade to "no threshold" (treat no attesters as zombies) + * rather than crashing. + */ +export async function getLocalEjectionThreshold( + rollupAddress: `0x${string}` | string, + client: any +): Promise { + try { + const threshold = await client.readContract({ + address: rollupAddress as Address, + abi: ROLLUP_ABI, + functionName: "getLocalEjectionThreshold", + }); + + return (threshold as bigint).toString(); + } catch (error) { + console.error(`Failed to get local ejection threshold for ${rollupAddress}:`, error); + return '0'; + } +} + /** * Gets reward configuration from rollup contract */