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
11 changes: 7 additions & 4 deletions atp-indexer/src/api/handlers/provider/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
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)
Expand Down Expand Up @@ -150,9 +151,10 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
// 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;
Expand All @@ -163,7 +165,8 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
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;
}
Expand Down
10 changes: 9 additions & 1 deletion atp-indexer/src/api/handlers/provider/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export async function handleProviderList(c: Context): Promise<Response> {
db,
activationThreshold: BigInt(activationThreshold),
ejectionThreshold: BigInt(ejectionThreshold),
canonicalRollupAddress: normalizeAddress(rollupAddress) as `0x${string}`,
});

// Combine ATP-based and ERC20-based delegations
Expand Down Expand Up @@ -128,6 +129,12 @@ export async function handleProviderList(c: Context): Promise<Response> {
// 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<string>();
Expand All @@ -137,7 +144,8 @@ export async function handleProviderList(c: Context): Promise<Response> {
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;
};
Expand Down
34 changes: 30 additions & 4 deletions atp-indexer/src/api/handlers/staking/summary.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -105,6 +106,18 @@ export async function handleStakingSummary(c: Context): Promise<Response> {
totalAmount: sql<bigint>`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)
]);
Expand Down Expand Up @@ -221,15 +234,28 @@ export async function handleStakingSummary(c: Context): Promise<Response> {
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.
Expand Down
16 changes: 15 additions & 1 deletion atp-indexer/src/api/utils/attester-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sql } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import {
deposit,
slashed,
Expand Down Expand Up @@ -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}`;
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -178,6 +191,7 @@ export async function buildAttesterStateLookup({
totalAmount: sql<bigint>`SUM(${slashed.amount})`.as("total_slashed"),
})
.from(slashed)
.where(eq(slashed.rollupAddress, canonicalRollupAddress))
.groupBy(slashed.attesterAddress),
]);

Expand Down
Loading