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/screeninganswers “who are the largest indexed holders I should screen?” from a bounded immutable Top-N.GET /v1/{chain}/token/{token}/holderspages 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. Published policy addresses are compact,
lowercase, nonzero, and strictly below the Starknet field prime. The SDK and
browser validate every response against the requested chain and token before
accepting rows, including continuation pages; a differently scoped response is
a non-retryable response-format failure. Padded hexadecimal request addresses
are compared by their canonical identity.
Supported mainnet aliases (mainnet, SN_MAINNET, and its chain felt) resolve
to SN_MAIN; Sepolia aliases resolve to SN_SEPOLIA. Holder rows exclude the
zero mint/burn endpoint. A complete empty population has total "0", not
a positive balance; a redacted or legacy unproven null total is not an
empty-population certificate.
The launch policy gives these tokens snapshot and certification-queue priority plus screening depth; it does not limit all-token terminal accounting.
| Token | Screening Top-N |
|---|---|
| STRK, ETH, USDC, WBTC | 200 |
| EKUBO, USDT | 100 |
| strkBTC | 50 |
| SolvBTC, tBTC, xstrkBTC, xWBTC, xtBTC, xsBTC | 10 |
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.
Qualification is separate from discovery
The offline universe terminally accounts for every discovered fungible token,
but discovery, metadata, a symbol, or a successful balance_of sample does
not grant access to a holder route. Each of /holders, /holders/screening,
and /holders/analytics requires a current explicit address-keyed policy.
For a token without one, the API returns HTTP 422 with
code="unqualified_token_policy". This is terminal and non-retryable: it has
no Retry-After, and retrying cannot create a policy review.
Do not confuse that response with either prepared-data state. A qualified
priority token without a current certificate returns the deliberately redacted
200 tuple above, not an empty population. A qualified token whose prepared
generation or screening projection is temporarily unavailable returns
retryable 503 with Retry-After; retry the same request in that case.
Capabilities discovery advertises the current address-keyed policies, but does
not turn an unreviewed discovered token into an eligible one.
The registry's fallback is a denial disposition, not a policy profile: it
creates no default screening depth, queue priority, freshness SLO, or adapter
authority for a token absent from both policyRegistry.items and the optional
policyRegistry.additionalQualifiedItems.
The same registry can qualify additional tokens without changing the launch
cohort. items contains the approved 13 priority tokens;
additionalQualifiedItems, when present, contains independently reviewed
all-token policies. Both arrays require canonical address identity, unique
addresses, display symbols and priorities, behavior-bound adapters, explicit
SLOs and the same certification policy. Queue scheduling and universe seeding
use both arrays; cohort observers and the stricter priority serving contract
use only items. Adding a reviewed policy does not certify its population,
and discovery never adds a policy automatically. The first release currently
has no additional qualified policies.
Each policy also exposes display name, netWorthEligible,
holderScreeningEnabled, priorityTier, adapterQualificationRevision and
sourceProvenance (the repository issue and selection rationale). Display
names are not identity keys. Wallet eligibility does not guarantee a usable
price; screening eligibility does not prove a complete holder population.
Tiers A/B/C/D describe Top-200/100/50/10 read budgets, with standard for other
reviewed policies. The separate explicit SLO fields are targets, not live
freshness measurements. Unknown adapter revisions or inconsistent tier/depth
policies fail validation rather than silently receiving a default.
A reviewed policy identity is not, by itself, complete-population adapter authority. Native ETH and rebasing-style tokens remain redacted and non-exact until their behavior-specific adapter can prove the complete population. A bounded screening projection may still be useful where it is available, but it is never a complete-population claim.
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.
The browser rejects a continuation that restarts ranks, repeats any retained holder, breaks balance/address order, or ends before the manifest count. It keeps the last good page and asks for a reload. SDK initial-page requests must start at rank 1; applications walking SDK cursors must retain and check the cross-page invariants shown below.
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.
Before a new reconstruction-backed complete generation can publish, its
private generation-bound import must contain paired exact-block
total_supply/totalSupply and zero-address-balance observations for that
generation's snapshot.asOfBlockHash. Both values must be U256 values and
must satisfy totalSupply = holderBalanceTotalRaw + zeroAddressBalance.
Missing, unpaired, or non-conserving observations reject promotion. Those
import witnesses are not public response fields. Supply conservation is an
additional check, not proof of adapter behavior or the absence of omitted
holders; RPC corrections of known holders do not earn population completeness.
A newer generation does not change an unexpired cursor's rows. However, historical source corrections can invalidate its completeness proof. Check completeness and certification on every page, including retained pages; an old certificate cannot override invalidated source authority.
Treat the typed states literally:
| State | Consumer action |
|---|---|
422 unqualified_token_policy | Terminal eligibility result. Do not retry or replace it with a zero/redacted holder page; the token needs an explicit address-keyed policy review. |
Retryable 503 | Retry after Retry-After; do not substitute zero. |
population_coverage_unproven | The generation may be pageable, but it is not a complete-population claim. |
uncertified | Do not claim exactness. |
stale | Inspect block lag and checkedAt; priority-cohort rows remain withheld until a current complete generation is certified. |
priority_cohort_freshness_slo_exceeded | The 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_failed | Do not use the invalidated certification. The last good immutable generation may remain served with truthful freshness. |
certification_not_run or certification_table_missing | Treat 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_drift | Restart the walk from page one; do not combine pages from different snapshots. |
unavailable | No 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.