SOLSTACKAcademy

Builder· Lesson 8 of 8· 5 min

Reading chain data at scale

One account is an RPC call. A token's ten thousand holders is an architecture decision. The four ways to read the chain, what each costs, and the caching pattern Solstack uses to make holder scans affordable.

Plain RPC, batched

TypeScript
// one account
const info = await connection.getAccountInfo(address);

// up to 100 per call; chunk anything larger
const chunk = (arr, n) => Array.from({ length: Math.ceil(arr.length / n) }, (_, i) => arr.slice(i * n, i * n + n));

const infos = (
  await Promise.all(chunk(addresses, 100).map((batch) => connection.getMultipleAccountsInfo(batch)))
).flat();

// parsed token accounts for one wallet, one mint — RPC decodes the layout for you
const { value } = await connection.getParsedTokenAccountsByOwner(wallet, { mint }, "confirmed");
const balance = value[0]?.account.data.parsed.info.tokenAmount.uiAmountString ?? "0";

getProgramAccounts: powerful, and often refused

every holder of a mintTypeScript
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";

const holders = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
  commitment: "confirmed",
  filters: [
    { dataSize: 165 },                                  // token accounts only
    { memcmp: { offset: 0, bytes: mint.toBase58() } },  // for this mint (mint is the first field)
  ],
  dataSlice: { offset: 32, length: 40 },              // owner (32) + amount (8): skip the rest
});

for (const { pubkey, account } of holders) {
  const owner = new PublicKey(account.data.subarray(0, 32));
  const amount = account.data.readBigUInt64LE(32);
}

This is a full scan of every account the program owns, filtered on the node. For the Token program that is hundreds of millions of accounts, which is why public RPCs refuse it or cap the response and paid providers meter it. Always pass dataSize and a memcmp, and a dataSlice so the bytes you don't need never cross the wire. For your own program's accounts, with thousands rather than millions, it is the right tool — Calling a program shows the Anchor form.

Subscriptions

TypeScript
// an account's new state whenever it changes
const subId = connection.onAccountChange(vault, (info, context) => {
  console.log(context.slot, info.lamports, info.data.length);
}, "confirmed");

// every transaction that mentions a program, with its logs
const logSub = connection.onLogs(TOKEN_LOCKER_PROGRAM_ID, ({ signature, logs, err }) => {
  if (!err) console.log(signature, logs.filter((l) => l.startsWith("Program data:")));
}, "confirmed");

// always clean up, and expect to reconnect: a dropped socket delivers nothing and throws nothing
await connection.removeAccountChangeListener(subId);
await connection.removeOnLogsListener(logSub);

Websocket subscriptions are the cheapest way to react to a small, known set of accounts. They are not a source of truth: sockets drop, providers restart, and a missed notification is silent. Pair every subscription with a periodic poll of the same accounts, and treat the subscription as a hint to poll sooner.

The DAS API

The Digital Asset Standard API is a JSON-RPC extension offered by indexing providers (Helius, Triton and others). It answers questions the base RPC cannot answer in one call: every asset a wallet holds with metadata already joined, every token created by an authority, assets filtered by collection. It is an index, so it is fast and cheap — and only as complete as the provider's pipeline.

TypeScript
const res = await fetch(RPC_URL, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: "1",
    method: "getAssetsByOwner",
    params: {
      ownerAddress: wallet.toBase58(),
      page: 1,
      limit: 1000,
      displayOptions: { showFungible: true, showZeroBalance: false },
    },
  }),
});
const { result } = await res.json();
for (const asset of result.items) {
  console.log(asset.id, asset.content?.metadata?.name, asset.token_info?.balance);
}

Solstack's serial-deployer check is a lesson in the gap: getAssetsByAuthority finds every token whose update authority is a wallet, which catches ordinary launches — and is blind to pump.fun, which never sets that field to the creator. So src/solstack/lib/serialDeployer.js merges the DAS answer with a second source, a parsed-transaction history of the program's create instructions. Every index has a blind spot; know which one before you trust a zero.

Geyser: the firehose

A Geyser plugin streams every account update and transaction out of a validator as it happens, usually over gRPC (Yellowstone). It is how the indexers above are built. You want it when you are building an index of your own — a token's full holder history, a program's every event, a trading feed — and not before, because the volume is the whole chain.

Cache what cannot change

Holder forensics needs, for each of a token's top wallets, who funded it and when. That is an RPC lookup per wallet, and the same wallets show up in scan after scan. But a wallet's first transaction never changes, so the answer can be cached forever, across every token, in the Worker:

worker/walletFacts.js · the shapeJavaScript
// GET  /api/wallet-facts?wallets=a,b,c   → { a: { fundedBy, firstTs }, b: ..., c: null }
// POST /api/wallet-facts                  ← facts resolved by the client for wallets the cache lacked

export async function readFacts(env, wallets) {
  const keys = wallets.slice(0, 100).map((w) => `wf:${w}`);
  const hits = await Promise.all(keys.map((k) => env.KV.get(k, "json")));
  return Object.fromEntries(wallets.map((w, i) => [w, hits[i]]));
}

export async function writeFacts(env, facts) {
  await Promise.all(
    Object.entries(facts).map(([w, f]) => env.KV.put(`wf:${w}`, JSON.stringify(f))) // no TTL: permanent facts
  );
}

// client side: read the cache first, RPC only for misses, write the misses back
const cached = await getWalletFacts(wallets);
const misses = wallets.filter((w) => !cached[w]);
const resolved = await resolveFromRpc(connection, misses);
await putWalletFacts(resolved);

The rule generalises: cache by what changes it. A wallet's genesis never changes — cache forever. A mint's supply changes on every mint or burn — cache for seconds or subscribe. A price changes constantly — never cache past its timestamp, which is exactly the stale-price bug the portfolio view once had. Holder Snapshot and Token Inspector are built on all four layers above.

What to remember

  • Batch RPC reads 100 at a time; use parsed methods when the layout is standard.
  • getProgramAccounts is a scan. Filter by size and bytes, slice the data, and expect public RPCs to refuse it for large programs.
  • Subscriptions are hints, not truth. Poll alongside them and handle silent drops.
  • DAS answers joined questions cheaply, with blind spots per provider. Merge sources when a zero would matter.
  • Cache by what changes a value: forever for genesis facts, seconds for balances, never for prices.

Try it

0 of 32 lessons done