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: 11 additions & 0 deletions atp-indexer/src/abis/rollup.abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
62 changes: 51 additions & 11 deletions atp-indexer/src/api/handlers/provider/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,8 +29,9 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
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),
Expand Down Expand Up @@ -110,8 +112,20 @@ export async function handleProviderDetails(c: Context): Promise<Response> {
];
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
Expand All @@ -120,13 +134,39 @@ export async function handleProviderDetails(c: Context): Promise<Response> {

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<string>();
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 = {
Expand Down
104 changes: 81 additions & 23 deletions atp-indexer/src/api/handlers/provider/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,8 +29,16 @@ export async function handleProviderList(c: Context): Promise<Response> {
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,
Expand Down Expand Up @@ -59,6 +68,18 @@ export async function handleProviderList(c: Context): Promise<Response> {
}).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];

Expand All @@ -84,15 +105,43 @@ export async function handleProviderList(c: Context): Promise<Response> {
];
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<string>();
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<string, typeof validDelegations>();
const unassociatedStakesWithProvider: typeof validDelegations = [];
Expand All @@ -108,12 +157,8 @@ export async function handleProviderList(c: Context): Promise<Response> {
}
}

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
Expand All @@ -127,16 +172,25 @@ export async function handleProviderList(c: Context): Promise<Response> {
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
Expand Down Expand Up @@ -167,15 +221,19 @@ export async function handleProviderList(c: Context): Promise<Response> {
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()
};
}

Expand Down
Loading
Loading