@starkscan/sdk
v0.4.2
Published
TypeScript SDK for the Starkscan Starknet block explorer API.
Maintainers
Readme
@starkscan/sdk
TypeScript SDK for the Starkscan Starknet explorer API.
Release target: 0.4.2. This release adds the typed one-call wallet portfolio
contract for fungible balances, Vesu/Ekubo/Troves positions, and indexed
ERC-721 ownership while retaining the exact-verification methods. Use the
untagged install only after the live latest verifier passes; pin 0.4.2 for
reproducible installs after that promotion.
Install
npm install @starkscan/sdkExact pin for unattended services:
npm install @starkscan/[email protected]Prerelease tags such as @beta are maintainer-directed test channels only.
Normal users and agents should use the default package or the exact 0.4.2
pin above.
First request
import { createStarkscanClient } from '@starkscan/sdk';
const starkscan = createStarkscanClient({
apiKey: process.env.STARKSCAN_API_KEY!,
chainId: 'SN_MAIN',
});
const status = await starkscan.status();
console.log(status.chainId, status.headBlockNumber ?? status.latestIndexedBlockNumber);The SDK defaults to https://api.starkscan.co, sends
X-Starkscan-Api-Key, and uses the same REST contract as the CLI. Set
STARKSCAN_BASE_URL only for preview or self-hosted Starkscan hosts, and map
STARKSCAN_CHAIN to the SDK chainId option when you want env-driven clients.
The package is ESM-only and supports Node.js 18 or newer. CommonJS consumers
should use dynamic import('@starkscan/sdk').
Why use it
- Typed helpers over Starkscan REST routes.
- Centralized API-key and request metadata handling.
- Runtime response validation on high-level client calls.
- Data-honesty helpers for pagination, exactness, lag, and truncation flags.
- Chain-bound clients with
withChain('SN_SEPOLIA').
Useful reads
const block = await starkscan.block(8279910, 5);
const txs = await starkscan.blockTransactions(8279910, undefined, 25);
const tx = await starkscan.transaction('0x...');
const activity = await starkscan.addressActivity('0x...');
const discovery = await starkscan.walletAssetDiscovery('0x...', {
scope: 'discovered_plus_registry',
limit: 25,
});
const walletState = await starkscan.walletState({
ownerAddress: '0x...',
mode: 'require_complete',
scope: 'discovered_plus_registry',
limit: 25,
blockPreference: 'latest_accepted_l2',
});
const portfolio = await starkscan.walletPortfolio({
ownerAddress: '0x...',
mode: 'require_complete',
scope: 'discovered',
limit: 25,
nftLimit: 25,
});
const privacyPool = await starkscan.privacyPoolTvl();
const transfers = await starkscan.tokenTransfers('0xtoken', {
addresses: ['0xwalletA', '0xwalletB'],
limit: 100,
});For wallet-state responses, value a holding only when its balance status is
ok, price.status === 'priced', price.reasonCode ===
'fresh_exact_cached_price', and both USD fields are non-null. A partial
diagnostic may retain a unit price for an unverified balance, but never a wallet
value. The SDK rejects unknown or empty pricing reason codes.
metadata.spamStatus === 'not_assessed' means no spam
classification was performed; it is not a safety claim. See the
Wallets guide for pagination, retirement,
full-range event certification, and partner access.
walletPortfolio() requests fungible balances, supported DeFi positions, and
one indexed ERC-721 page in one round trip. The fungible and NFT cursors are
independent. walletSafe applies only to the selected fungible balance page;
read defiPositions.coverage and nftPositions.coverage separately, and never
treat nftPositions.coverage.globallyComplete as true.
privacyPoolTvl() returns finalized public deposit-minus-withdrawal accounting.
Use exact decimal-string protectedAmountRaw values as the source of truth.
valuation.totalUsd is deliberately null unless every asset has complete
amount decoding, known decimals, and a fresh exact cached quote; the SDK does
not fetch or guess missing prices. Require
coverage.decodedMaterializationFresh === true for a certified partner total;
false reports raw finalized flow events ahead of decoding and null means
the deployment has no raw comparison filter configured.
Event decoding
contractEvents, globalEvents, transaction(...).logs, and block-detail
event payloads expose the same server-certified event contract. Use complete
keys and data as the authoritative raw payload (topic0 through topic3
are compatibility aliases) and render decodingStatus as one of decoded,
name_only, or unknown. Typed decodedFields exists only for decoded;
provenance and unavailable reason fields explain the other states. Page/detail
eventDecodingDegraded signals an operational attribution-lookup failure, not
an individual unknown event. The SDK validates this contract and does not call
RPC, Voyager, ABI, or trace fallbacks on the request path.
const page = await starkscan.contractEvents('0xcontract', { limit: 100 });
for (const event of page.items) {
console.log(event.decodingStatus, event.eventName, event.keys, event.data);
}Data honesty helpers
Starkscan responses expose freshness, exactness, pagination, and truncation signals directly. Use helper functions instead of guessing whether a response is complete:
import { getLagBlocks, hasMorePages } from '@starkscan/sdk';
const discovery = await starkscan.walletAssetDiscovery('0x...', {
scope: 'discovered_plus_registry',
limit: 25,
});
const state = await starkscan.walletState({
ownerAddress: '0x...',
mode: 'require_complete',
scope: 'discovered_plus_registry',
limit: 25,
blockPreference: 'latest_accepted_l2',
});
if (!state.walletSafe) throw new Error('wallet snapshot is not complete within scope');
if (hasMorePages(await starkscan.addressActivity('0x...'))) {
console.log('Continue with nextCursor before summarizing totals');
}
const allCandidates = [];
let cursor: string | undefined;
let snapshotId: string | null | undefined;
do {
const page = await starkscan.walletAssetDiscovery('0x...', {
scope: 'discovered_plus_registry',
cursor,
limit: 25,
});
snapshotId ??= page.snapshotId;
if (page.snapshotId !== snapshotId) throw new Error('discovery snapshot changed');
allCandidates.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);
const status = await starkscan.status();
console.log('index lag', getLagBlocks(status));Complete token holder walks
The exported client validates holder responses at the network boundary,
including the requested chain and canonical token address on every page.
Responses for a different chain or token raise a non-retryable
ResponseFormatError; their holder rows are not retained in its payload.
Supported named/felt chain aliases resolve to canonical SN_MAIN or
SN_SEPOLIA in holder requests and responses. Holder rows exclude the zero transfer
endpoint. Zero-count pages cannot carry a positive total; a complete empty
population has total "0", while a null unavailable total is not population proof.
Initial requests start at rank 1 and each page rejects duplicate addresses.
SDK cursor walkers must also retain generation identity, contiguous ranks,
unique addresses and balance/address ordering across page boundaries; the
browser enforces these checks before retaining a continuation.
Use getTokenHolderScreening for the bounded policy Top-N used by whale-screening
workflows; use getTokenHolders for holder population pages, and make a
complete-population claim only after the exact predicate below holds for the
entire cursor walk.
Holder eligibility is independently typed. getTokenHolders serves only
tokens in the explicit address-keyed producer policy registry and never builds
a count or generation on the request path. For that enumeration method,
unknown or terminal non-ERC20 targets return 404, while positive indexed
fungible evidence without a current policy throws
UnqualifiedTokenPolicyError for API 422 unqualified_token_policy.
getTokenHolderScreening gates policy membership directly, so every
unregistered identity throws the same 422 regardless of indexed discovery
evidence. Its apiCode is unqualified_token_policy and retryable is
false; do not retry it as a generation build. A token without a current
certificate returns its rows as a non-exact 200 page. A token without a
sealed generation returns the canonical unavailable 200 tuple; transient
prepared-serving failures retain the retryable 503 lifecycle case.
The API's /v1/meta/capabilities policy registry exposes display name/symbol,
netWorthEligible, holderScreeningEnabled, priorityTier,
adapterQualificationRevision and sourceProvenance beside each canonical
address. These are selection and scheduling facts, not a price guarantee or
holder certificate. Use the returned holder generation's completeness and
certification fields for those decisions. The SDK's holder methods do not
silently fetch capabilities, RPC or explorer data on the request path.
import { createExplorerApi } from '@starkscan/sdk';
const explorer = createExplorerApi({
baseUrl: 'https://api.starkscan.co',
apiKey: process.env.STARKSCAN_API_KEY!,
});
function requireExactCompleteHolderSnapshot(
page: Awaited<ReturnType<typeof explorer.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('holder generation is not an exact complete population');
}
return snapshot;
}
let screeningCursor: string | undefined;
do {
const page = await explorer.getTokenHolderScreening(
'SN_MAIN',
'0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d',
screeningCursor,
100,
);
// Even when nextCursor is null, this is a bounded Top-N projection.
if (page.screening.populationComplete || page.screening.exact) {
throw new Error('invalid screening response');
}
screeningCursor = page.nextCursor ?? undefined;
} while (screeningCursor);
const holders = [];
let cursor: string | undefined;
let snapshotIdentity: string | undefined;
do {
const page = await explorer.getTokenHolders(
'SN_MAIN',
'0x4718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d',
cursor,
100,
);
const snapshot = requireExactCompleteHolderSnapshot(page);
const identity = JSON.stringify([
page.chainId,
page.tokenAddress,
snapshot.generationId,
snapshot.asOfBlock,
snapshot.asOfBlockHash,
snapshot.rowDigest,
page.holderCount,
page.holderBalanceTotalRaw,
]);
snapshotIdentity ??= identity;
if (identity !== snapshotIdentity) throw new Error('holder generation changed');
holders.push(...page.items);
cursor = page.nextCursor ?? undefined;
} while (cursor);validateTokenHolderPage and validateTokenHolderScreeningPage are also
exported for callers using the low-level HTTP client. Immutable generations expose generationId,
asOfBlockHash, rowDigest, and expiresAt together. The default retention is
six hours; the returned expiresAt is authoritative for the current cursor
walk but is not part of the durable generation identity. Treat cursor HTTP 400
invalid_request as a restart from page one. An exact population claim requires
the sealed finalized source with freshness='finalized_generation' and immutable IDs above,
populationComplete=true with
populationReasonCode='complete_canonical_transfer_coverage',
truncated=false, both reason codes equal to materialized_snapshot,
certification.status='certified' with
validatedAgainst='starknet_rpc_balanceOf', a certification timestamp no
earlier than publication, and an unexpired expiresAt. The SDK rejects an
exact=true response that violates that predicate. A full cursor walk with
populationComplete=false is only a complete walk of the published subset.
RPC balanceOf certifies bounded samples only and cannot enumerate every
holder.
The canonical cold unavailable holder 200 is the full tuple with
snapshot.source=materialized_fungible_balances, snapshot.asOfBlock=null,
holderCount=0, holderBalanceTotalRaw=null, no rows, nextCursor=null, and
Cache-Control: no-store. It also carries certification.status=unavailable,
but that status alone does not mean a generation is absent: a sealed generation
may carry unavailable certification while still serving immutable rows with
exact=false. It is not an empty population; do not cache it or
turn it into a zero holder total. Trust a served generation as a complete
population only when completeness.exact is true.
Historical USD coverage
Transfer historicalUsd values are transaction-time facts, not spot-price
fallbacks. Lightweight list and preview endpoints can intentionally omit this
enrichment, so historicalUsd can be null. When it is present, branch on the
paired coverage fields:
priced/priced: a materialized transaction-time USD valuation.unpriced/ a typed reason:outside_history_windowis a terminal provider policy result, whileprice_missingis an in-horizon repairable gap. Neither is an audit-grade transaction-time valuation.pending/materialization_pending: an eligible transfer that has not yet produced a materialization fact.
Price fields can be null for unpriced and pending outcomes. Do not replace
them with a current market quote when presenting an audit surface.
Public exact contract verification
Every active workspace API key includes the independent verify scope. The SDK
exposes the complete asynchronous lifecycle; deterministic package creation and
offline receipt authentication remain in the starkscan verify CLI.
import { createStarkscanClient } from '@starkscan/sdk';
import { readFile } from 'node:fs/promises';
const client = createStarkscanClient({
apiKey: process.env.STARKSCAN_API_KEY,
chainId: 'SN_MAIN',
});
const metadata = JSON.parse(await readFile('./contract.package.json', 'utf8'));
const archive = new Uint8Array(await readFile('./contract.source.tar.gz'));
const created = await client.createVerificationJob(
metadata,
'my-contract-release-2026-09-07',
);
await client.uploadVerificationSource(created.jobRef, archive);
const current = await client.verificationJob(created.jobRef);
if (current.state === 'succeeded') {
const receipt = await client.verificationReceipt(created.jobRef);
// Persist receipt, then authenticate it with `starkscan verify receipt-check`.
}Create, upload, and cancel are never retried by the SDK. Reuse the same
Idempotency-Key only to resolve an ambiguous create response. Uploads are
capped at 64 MiB and must be the exact gzip bytes committed by Package V1. The
policy-bound source portion remains capped at 10 MiB; the additional allowance
is only for lockfile-bound offline dependency material. See
the complete verification guide
for target selectors, retention, terminal states, privacy, and proof limits.
Low-level HTTP client
Prefer createStarkscanClient() for application code. If you use HttpClient
directly, generic calls such as getJson<T>(), postJson<T>(), or
putJson<T>() return unknown unless you pass a JsonResponseValidator<T>.
That keeps network-boundary validation explicit.
await http.getJson<MyEnvelope>(path, undefined, validateMyEnvelope);
await http.postJson<MyEnvelope>(path, body, validateMyEnvelope);
await http.putJson<MyEnvelope>(path, body, validateMyEnvelope);putBytes() is available for bounded binary uploads and is deliberately never
retried. A custom HttpClient must implement it before using
uploadVerificationSource().
Trust and safety
- npm package: https://www.npmjs.com/package/@starkscan/sdk
- SDK docs: https://starkscan.co/docs/sdk/typescript
- API docs: https://starkscan.co/docs/api
- Package trust: https://starkscan.co/docs/build/package-trust
- Machine-readable launch matrix: https://starkscan.co/public-client-surface-matrix.json
- Socket signal: https://socket.dev/npm/package/@starkscan/sdk
Socket is an external package-risk signal, not a Starkscan security certificate. The public trust source is Starkscan docs because the canonical engineering repository is private. Package promotion also requires checked release scripts, packed-tarball smoke, npm Trusted Publishing/OIDC for CI publishes, and live API smoke proof.
When a release makes a runtime validator require a newly added response member,
deploy the exact-main API first and prove that the member is always present on
the live response, using null where the value is unknown. Publish the SDK only
after that backend-first compatibility proof. This prevents a new SDK from
rejecting responses from a supported Starkscan deployment during rollout.
Release channels
latest: stable channel; verify that it resolves to0.4.2before relying on the one-call wallet portfolio contract.beta: prerelease channel for explicit tests only.alpha: historical prerelease channel; use only when directed during rollback.
Server-side key tiers control route access. The package stays the same; entitlements do not.
