diff --git a/atp-indexer/src/api/handlers/provider/details.ts b/atp-indexer/src/api/handlers/provider/details.ts index e91d44452..ad9884ee2 100644 --- a/atp-indexer/src/api/handlers/provider/details.ts +++ b/atp-indexer/src/api/handlers/provider/details.ts @@ -120,6 +120,7 @@ export async function handleProviderDetails(c: Context): Promise { db, activationThreshold: BigInt(activationThreshold), ejectionThreshold: BigInt(ejectionThreshold), + canonicalRollupAddress: normalizeAddress(rollupAddress) as `0x${string}`, }); // Extract only the valid + ACTIVE delegations (ignore direct stakes — they were just for FIFO) @@ -150,9 +151,10 @@ export async function handleProviderDetails(c: Context): Promise { // 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. + // per UNIQUE attester. The `attesterState` lookup filters slashes + // to the canonical rollup, so per-attester sums are bounded by + // activation; the cap below is defense-in-depth. + const activationThresholdBig = BigInt(activationThreshold); let totalDelegations = 0n; { let nominal = 0n; @@ -163,7 +165,8 @@ export async function handleProviderDetails(c: Context): Promise { const normalized = normalizeAddress(s.attesterAddress); if (seenAttesters.has(normalized)) continue; seenAttesters.add(normalized); - totalSlashes += attesterState(normalized).totalSlashed; + const raw = attesterState(normalized).totalSlashed; + totalSlashes += raw > activationThresholdBig ? activationThresholdBig : raw; } totalDelegations = nominal > totalSlashes ? nominal - totalSlashes : 0n; } diff --git a/atp-indexer/src/api/handlers/provider/list.ts b/atp-indexer/src/api/handlers/provider/list.ts index 8d0c6616e..a4b9ae303 100644 --- a/atp-indexer/src/api/handlers/provider/list.ts +++ b/atp-indexer/src/api/handlers/provider/list.ts @@ -78,6 +78,7 @@ export async function handleProviderList(c: Context): Promise { db, activationThreshold: BigInt(activationThreshold), ejectionThreshold: BigInt(ejectionThreshold), + canonicalRollupAddress: normalizeAddress(rollupAddress) as `0x${string}`, }); // Combine ATP-based and ERC20-based delegations @@ -128,6 +129,12 @@ export async function handleProviderList(c: Context): Promise { // 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. + // + // Per-attester slash is also capped at the activation threshold. + // The `attesterState` lookup already filters slashes to the + // canonical rollup only, so per-attester sums are mathematically + // bounded by activation. The cap here is defense-in-depth. + const activationThresholdBig = BigInt(activationThreshold); const sumEffectiveBalance = (stakes: { attesterAddress: string; stakedAmount: bigint | string }[]): bigint => { let nominal = 0n; const seenAttesters = new Set(); @@ -137,7 +144,8 @@ export async function handleProviderList(c: Context): Promise { const normalized = normalizeAddress(s.attesterAddress); if (seenAttesters.has(normalized)) continue; seenAttesters.add(normalized); - totalSlashes += attesterState(normalized).totalSlashed; + const raw = attesterState(normalized).totalSlashed; + totalSlashes += raw > activationThresholdBig ? activationThresholdBig : raw; } return nominal > totalSlashes ? nominal - totalSlashes : 0n; }; diff --git a/atp-indexer/src/api/handlers/staking/summary.ts b/atp-indexer/src/api/handlers/staking/summary.ts index f3f95371c..ee81defb7 100644 --- a/atp-indexer/src/api/handlers/staking/summary.ts +++ b/atp-indexer/src/api/handlers/staking/summary.ts @@ -1,10 +1,11 @@ import type { Context } from 'hono'; import { db } from 'ponder:api'; -import { count, sql } from 'drizzle-orm'; +import { count, eq, sql } from 'drizzle-orm'; 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 { normalizeAddress } from '../../../utils/address'; import type { StakingSummaryResponse } from '../../types/staking.types'; import { stakedWithProvider, @@ -105,6 +106,18 @@ export async function handleStakingSummary(c: Context): Promise { totalAmount: sql`SUM(${slashed.amount})`.as('total_slashed'), }) .from(slashed) + // Canonical-rollup only. Each rollup tracks its own + // `effectiveBalance` (per `GSE.effectiveBalanceOf(instance, attester)`), + // and the protocol's `StakingLib.slash` caps emission via + // `Math.min(_amount, effectiveBalance)`. So per-attester + // per-rollup slashes are bounded by activation. But aggregating + // across rollups historically (e.g., attester slashed on legacy + // A then on canonical B after `moveWithLatestRollup` migration) + // sums multiple separate deductions and can exceed activation — + // the empirical green-deploy TVL=0 was exactly this. For + // canonical TVL we only care about slashes that happened on + // the rollup whose `effectiveBalance` we're trying to reflect. + .where(eq(slashed.rollupAddress, normalizeAddress(rollupAddress) as `0x${string}`)) .groupBy(slashed.attesterAddress), getActiveAttesterCount(rollupAddress, client) ]); @@ -221,15 +234,28 @@ export async function handleStakingSummary(c: Context): Promise { zombieSlashCutoff, }); + // Defense-in-depth cap at activation threshold. Per the v4 + // `StakingLib.slash` source, the contract emits the + // *capped* `slashAmount = Math.min(_amount, effectiveBalance)`, + // and we've filtered the SQL to canonical-rollup-only above — + // so per-attester sum is mathematically bounded by activation + // already. The cap here is belt-and-braces against a future + // contract version that emits notional amounts, an indexer + // event-duplication bug, or any other shape we haven't + // anticipated. + const cappedSlash = r.totalAmount > activationThresholdBig + ? activationThresholdBig + : r.totalAmount; + switch (status) { case "ACTIVE": - slashedActive += r.totalAmount; + slashedActive += cappedSlash; break; case "EXITING": - slashedExiting += r.totalAmount; + slashedExiting += cappedSlash; break; case "ZOMBIE": - slashedZombie += r.totalAmount; + slashedZombie += cappedSlash; break; // EXITED / NOT_REGISTERED: tokens are gone (or never there); // their slashes don't affect on-contract TVL. diff --git a/atp-indexer/src/api/utils/attester-state.ts b/atp-indexer/src/api/utils/attester-state.ts index b283c6503..e6a047575 100644 --- a/atp-indexer/src/api/utils/attester-state.ts +++ b/atp-indexer/src/api/utils/attester-state.ts @@ -1,4 +1,4 @@ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { deposit, slashed, @@ -128,6 +128,18 @@ interface BuildInputs { db: any; activationThreshold: bigint; ejectionThreshold: bigint; + /** + * Canonical rollup address. Used to filter `slashed` to only those + * events that happened on the current canonical rollup — slashes on + * legacy rollups (against attesters who have since migrated to + * canonical via `moveWithLatestRollup`) are historical: they don't + * affect the attester's current on-canonical balance and including + * them double-counts when an attester was slashed on multiple + * rollups during a chain of migrations. Without this filter, + * cumulative `SUM(amount)` per attester can blow past + * `activationThreshold` and silently zero out headline TVL. + */ + canonicalRollupAddress: `0x${string}`; } /** @@ -145,6 +157,7 @@ export async function buildAttesterStateLookup({ db, activationThreshold, ejectionThreshold, + canonicalRollupAddress, }: BuildInputs): Promise<(attesterAddress: string) => AttesterState> { const [depositAggregates, latestInitiates, latestFinalizes, slashSums] = await Promise.all([ // Both the "ever deposited" set and the per-attester latest deposit @@ -178,6 +191,7 @@ export async function buildAttesterStateLookup({ totalAmount: sql`SUM(${slashed.amount})`.as("total_slashed"), }) .from(slashed) + .where(eq(slashed.rollupAddress, canonicalRollupAddress)) .groupBy(slashed.attesterAddress), ]);