Starkscan

Token holders and whale screening

Choose bounded Top-N screening or a complete immutable holder walk without overstating coverage.

Token holders and whale screening

Starkscan has two token-contract-first holder workflows. They are not wallet portfolio APIs:

  • GET /v1/{chain}/token/{token}/holders/screening answers “who are the largest indexed holders I should screen?” from a bounded immutable Top-N.
  • GET /v1/{chain}/token/{token}/holders pages one complete immutable generation when that generation is available.
  • Wallet net worth and address-to-token holdings use wallet-state. They answer a different question and must not be inferred from a token holder list.

Both holder routes are partner-tier, accept pages of 1 through 100 rows, and order by balanceRaw descending with canonical holder address ascending as the tie-breaker. They read prepared PostgreSQL rows only. The request path does not call RPC, scan transfer history, repair data, or fall back to another explorer.

The first policy cohort

Token identity is (canonical chain, canonical token address). Symbols below are display metadata, never lookup keys. The launch policy gives these tokens snapshot and certification-queue priority plus screening depth; it does not limit the all-token generation universe.

TokenScreening Top-N
STRK, ETH, USDC, WBTC200
EKUBO, USDT100
strkBTC50
SolvBTC, tBTC, xstrkBTC, xWBTC, xtBTC, xsBTC10

These values are consumer screening budgets. They do not truncate stored complete generations, redefine holderCount, or establish population completeness. For these 13 priority tokens, /holders withholds the holder count, total, rows, and cursor until one immutable generation is complete, generation-bound certified, and within that token's policy freshness SLO. That intentionally redacted 200 response is Cache-Control: no-store: it has holderCount=0, holderBalanceTotalRaw=null, items=[], nextCursor=null, populationComplete=false, and exact=false. That tuple is not an empty-population claim and must not be cached by a client. /holders/screening remains a useful bounded whale-screening projection, but never becomes a substitute for the complete walk.

Bounded screening

curl -H "X-Starkscan-Api-Key: $STARKSCAN_API_KEY" \
  "https://api.starkscan.co/v1/SN_MAIN/token/0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d/holders/screening?limit=100"

The response has screening.kind="top_k_screening", requestedTopN, and returnedCount. requestedTopN is resolved from the server-side address policy, not supplied by the request; returnedCount is the projection's total row count across all pages, not the current page length or the token's holderCount. It always has populationComplete=false, exact=false, and reasonCode="screening_projection_not_population_proof". When nextCursor=null, only the Top-N projection is exhausted. It does not prove that no other holder exists. Immutable screening responses include updatedAt and lagBlocks; use them with the pinned block/hash to enforce your freshness budget instead of treating a successful request as proof that the projection is current.

Complete generation walk

Start without a cursor. Keep the first page's chain, token, generation ID, block number and hash, row digest, holder count, and total balance as the walk identity. Pass every nextCursor back unchanged until it is null. A correct walk has one stable identity, unique addresses, and contiguous ranks from 1 through holderCount. Every immutable response also exposes updatedAt and lagBlocks. updatedAt is the generation publication time; lagBlocks is the difference between its pinned block and the indexed finalized head observed for that response.

import { createExplorerApi } from '@starkscan/sdk';

const api = createExplorerApi({
  baseUrl: 'https://api.starkscan.co',
  apiKey: process.env.STARKSCAN_API_KEY!,
});

function requireExactCompleteHolderSnapshot(
  page: Awaited<ReturnType<typeof api.getTokenHolders>>,
) {
  const { certification, completeness, snapshot } = page;
  if (
    snapshot.source !== 'sealed_finalized_holder_generation' ||
    snapshot.freshness !== 'finalized_generation'
  ) {
    throw new Error('holder walk is not bound to an immutable generation');
  }

  const now = Date.now();
  const maximumFutureSkewMs = 5 * 60_000;
  const updatedAt = Date.parse(snapshot.updatedAt);
  const expiresAt = Date.parse(snapshot.expiresAt);
  const checkedAt = certification.checkedAt ? Date.parse(certification.checkedAt) : Number.NaN;
  if (
    completeness.populationComplete !== true ||
    completeness.populationReasonCode !== 'complete_canonical_transfer_coverage' ||
    completeness.exact !== true ||
    completeness.truncated !== false ||
    completeness.reasonCode !== 'materialized_snapshot' ||
    certification.status !== 'certified' ||
    certification.validatedAgainst !== 'starknet_rpc_balanceOf' ||
    certification.reasonCode !== 'materialized_snapshot' ||
    !Number.isSafeInteger(snapshot.generationId) ||
    snapshot.generationId <= 0 ||
    !Number.isSafeInteger(snapshot.asOfBlock) ||
    snapshot.asOfBlock < 0 ||
    !/^0x[0-9a-f]+$/i.test(snapshot.asOfBlockHash) ||
    !/^sha256:[0-9a-f]{64}$/i.test(snapshot.rowDigest) ||
    !Number.isFinite(updatedAt) ||
    !Number.isFinite(expiresAt) ||
    !Number.isFinite(checkedAt) ||
    updatedAt > now + maximumFutureSkewMs ||
    checkedAt < updatedAt ||
    checkedAt > now + maximumFutureSkewMs ||
    expiresAt <= now
  ) {
    throw new Error('complete certified holder generation unavailable');
  }
  return snapshot;
}

let cursor: string | undefined;
let identity: string | undefined;
const seen = new Set<string>();
let expectedRank = 1;
let holderCount: number | undefined;
const tokenAddress = '0x...';

do {
  const page = await api.getTokenHolders('SN_MAIN', tokenAddress, cursor, 100);
  const snapshot = requireExactCompleteHolderSnapshot(page);
  const nextIdentity = JSON.stringify([
    page.chainId,
    page.tokenAddress,
    snapshot.generationId,
    snapshot.asOfBlock,
    snapshot.asOfBlockHash,
    snapshot.rowDigest,
    page.holderCount,
    page.holderBalanceTotalRaw,
  ]);
  identity ??= nextIdentity;
  if (identity !== nextIdentity) throw new Error('generation changed');
  holderCount ??= page.holderCount;
  for (const row of page.items) {
    if (seen.has(row.address)) throw new Error('duplicate holder');
    if (row.rank !== expectedRank) throw new Error('non-contiguous holder rank');
    seen.add(row.address);
    expectedRank += 1;
  }
  cursor = page.nextCursor ?? undefined;
} while (cursor);

if (holderCount === undefined || seen.size !== holderCount) {
  throw new Error('terminal holder count does not match the generation manifest');
}

For 10, 50, and 100 rows, request limit=10, 50, or 100. For 200 rows, request limit=100 and follow the one continuation cursor. Do not increase the page size or construct cursors. Cursors are opaque, authenticated, and bound to the immutable generation, watermark, scope, rank, and expiry.

Exactness and failure states

nextCursor describes page coverage. It is independent of correctness. populationComplete=true requires a genesis-to-snapshot coverage commit with continuous block/hash certificates, reconciled transaction, receipt, raw-event and decoded Transfer counts, one valid disposition per candidate event, the current parser revision, zero unresolved dispositions, and a generation whose count, total, digest, and block identity match that commit.

exact=true additionally requires a qualified token adapter and bounded balance_of samples at the exact snapshot block hash. Sampling can detect a wrong balance. It cannot prove that an omitted holder does not exist. For an immutable generation, the certification must also bind that exact generation ID, block/hash, row digest, count, total, coverage authority, and cursor-retention identity. It records the exported manifest count before the RPC sample and the count re-read in the certification transaction; both must equal the immutable generation count. A token-scoped legacy sample is not a substitute.

Treat the typed states literally:

StateConsumer action
Retryable 503Retry after Retry-After; do not substitute zero.
population_coverage_unprovenThe generation may be pageable, but it is not a complete-population claim.
uncertifiedDo not claim exactness.
staleInspect block lag and checkedAt; priority-cohort rows remain withheld until a current complete generation is certified.
priority_cohort_freshness_slo_exceededThe reviewed token's immutable generation is older than its address-keyed freshness SLO (or has a future publication time). Treat the zero count and empty rows as a redaction, not an empty population; retry after a new generation is published.
revoked or audit_failedDo not use the invalidated certification. The last good immutable generation may remain served with truthful freshness.
certification_not_run or certification_table_missingTreat the page as uncertified and inexact. For a priority token, including a legacy receipt missing either count witness, this is the redacted no-store tuple rather than a pageable zero.
cursor_snapshot_driftRestart the walk from page one; do not combine pages from different snapshots.
unavailableNo usable prepared projection is available.

For any redacted holder page, including a priority-token redaction, respect Cache-Control: no-store even if an older generation was once certified. The browser holder tab uses the same rule: it shows neither a placeholder zero nor partial rows until the current generation is complete and exact.

A rollout durability check must observe the priority cohort over the full approved monitoring interval (currently 24 hours), recording sanitized generation identity, updatedAt, lagBlocks, ordering, paging, and latency at each sample. One green request is not freshness or last-good-generation proof.

Malformed, expired, cross-token, or cross-generation cursors return 400 invalid_request. Discard the partial walk and restart without a cursor. A capacity 503 is different: retry the same cursor.

Token behavior and adapters

Standard ERC-20 tokens use canonical Transfer-ledger reconstruction. ETH uses the Starknet native fee-token adapter. Wrappers and receipt/share tokens use their qualified share-ledger adapter. Rebasing tokens require a rebase-aware supply and balance authority. Malformed, nonstandard, or behavior-changing contracts remain unqualified or inexact until a specific adapter proves their semantics. A symbol match, metadata row, or successful balance_of call is not an adapter and is not population proof.

Discovery and limits

Read /v1/meta/capabilities for the current address-keyed policy registry, route templates, page limit, ordering, SLOs, behavior class, adapter, and certification policy. The priority registry is chain-specific: a deployment whose default chain does not match the registry returns token-holder status unavailable with reason policy_registry_chain_mismatch and omits the registry instead of advertising another chain's policy. The machine-readable source contract is starkscan-openapi.yaml. For a key-tier mismatch, use the API-key contact path shown by the product; do not switch to internal routes or place keys in URLs.

On this page