Solana Tracker LogoSolana Tracker
Swap
Developers
⌘K
Affiliate
All Resources
Yellowstone gRPC Account Subscribe: Stream Token Balances
InfrastructureTypeScriptOctober 7, 202615 min readSolana Tracker

Yellowstone gRPC Account Subscribe: Stream Token Balances

Yellowstone gRPC account subscribe in TypeScript: stream a wallet's SOL and token balance changes with owner and memcmp filters, closures and backfill.

  • yellowstone-grpc,
  • geyser,
  • token-accounts,
  • wallet-tracking,
  • typescript
Yellowstone gRPC›Solana RPC›Dedicated Nodes›

A Yellowstone gRPC account subscribe streams the new state of an account every time a transaction writes to it, filtered on the server by address, owner program, data size or byte pattern. To track a wallet's balances, subscribe to the wallet address for SOL and to the two token programs with a memcmp filter on the owner field at byte 32, then decode mint and amount straight from the account bytes. This guide builds that subscription in TypeScript with @triton-one/yellowstone-grpc 8.x, including closures, duplicate updates, reconnects and backfill.

Account streams are part of Solana Tracker Yellowstone gRPC, which is also included on Business and Professional Solana RPC plans and on every dedicated node. If you have never opened a gRPC stream, start with the Yellowstone gRPC tutorial; this guide assumes you know how pings and reconnects work.

Account updates vs transaction updates

Both filters see the same writes, from different ends. A transaction update tells you what happened and makes you work out the resulting state. An account update tells you the resulting state and makes you work out what happened. For balances, positions and pool reserves, the account side is less code and harder to get wrong.

ApproachWhat you receiveYou still have toGood fit
gRPC account filterFull account data after each write, with slot, writeVersion and signatureDecode the account layout, track previous valuesBalances, pool reserves, program state, many accounts per wallet
gRPC transaction filterFull transaction with pre and post token balancesFind the right balance entry per account and handle inner instructionsSwap and transfer history, PnL, copy trading
RPC WebSocket (accountSubscribe, programSubscribe)One notification stream per subscription; programSubscribe accepts memcmp and dataSize filtersManage one subscription per address or filter, resubscribe after every reconnectA handful of accounts, light workloads
Datastream wallet balance roomIndexed balance updates for a wallet (amounts, no prices)Nothing; already parsed. Premium plan or higherDashboards and alerts without running decoders

If you want parsed, priced balances and do not need raw account data, the Data API gives you a wallet's holdings over REST and live balance updates through Datastream; see the Solana wallet portfolio API guide. Choose gRPC account streams when you need every write, at the commitment you pick, for accounts the indexed APIs do not cover.

How Yellowstone gRPC account filters work

Account filters live in the accounts map of the SubscribeRequest. Each entry has a name you choose and three fields that the 8.x types require: account, owner and filters.

FieldTypeMatches
accountstring[] (base58)These exact addresses
ownerstring[] (base58)Accounts owned by any of these programs
filters[].datasizestringAccounts whose data is exactly this many bytes
filters[].memcmp`{ offset: string; base58 \base64 \bytes }`Accounts with these bytes at this offset
filters[].tokenAccountStatebooleanAccounts whose data parses as a token account
filters[].lamports`{ eq \ne \lt \gt: string }`Accounts by lamport balance
nonemptyTxnSignatureboolean (optional)Only writes that carry a transaction signature

Values inside account and inside owner are ORed. Everything in filters is ANDed, and ANDed with owner. Separate named entries are ORed with each other, and every update carries a filters array naming the entries it matched. Numbers such as offsets and sizes are strings because they are 64-bit protobuf integers.

The request also has a top-level accountsDataSlice list of { offset, length } ranges. It trims the data for every account in the subscription, which saves bandwidth when you only need a few fields. Do not slice when one subscription mixes accounts with different layouts, as this one does.

The token account layout you filter on

SPL Token and Token-2022 accounts share the same 165-byte base layout. Token-2022 accounts append extensions after it, so their size varies.

BytesFieldNotes
0–31mintThe token this account holds
32–63ownerThe wallet that controls it; the memcmp target
64–71amountRaw amount, u64 little-endian, in base units
72–164delegate, state, native flag, delegated amount, close authorityRarely needed for balances

Token accounts do not store decimals. They live in the mint account at byte 44, so read them once per mint and cache them. The companion project does this with a one-byte getAccountInfo data slice.

Yellowstone gRPC account subscribe: the filter set

One wallet needs three filters, plus a fourth that grows as you learn about token accounts:

const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";

accounts: {
  // SOL: the wallet account itself.
  wallet: { account: [wallet], owner: [], filters: [] },
  // Classic SPL: fixed 165-byte size, owner field at byte 32.
  spl: {
    account: [],
    owner: [TOKEN_PROGRAM],
    filters: [{ datasize: "165" }, { memcmp: { offset: "32", base58: wallet } }],
  },
  // Token-2022: size varies with extensions, so match the account state instead.
  token2022: {
    account: [],
    owner: [TOKEN_2022_PROGRAM],
    filters: [{ tokenAccountState: true }, { memcmp: { offset: "32", base58: wallet } }],
  },
  // Every token account seen so far, by address, so closures are visible.
  known: { account: knownTokenAccounts, owner: [], filters: [] },
}

The datasize filter on the classic program matters: without it, an owner-field match at byte 32 could also hit other account types the program owns. On Token-2022 a fixed size would miss any account with extensions, which is why tokenAccountState does that job there.

The known entry handles a gap that catches most first implementations. When a token account is closed, its data is wiped and its owner becomes the System Program. It no longer matches owner or memcmp, so the owner-based filters never tell you it closed. An address filter still matches, so the stream delivers the final write with zero lamports and empty data.

Full TypeScript example

Set up a project and install the client:

npm init -y && npm pkg set type=module
npm install @triton-one/yellowstone-grpc@^8.0.0 bs58@^6.0.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
# Yellowstone gRPC endpoint: https://grpc.solanatracker.io (EU) or https://grpc-us.solanatracker.io (US)
YELLOWSTONE_GRPC_ENDPOINT=
# x-token from https://www.solanatracker.io/account/yellowstone-grpc
YELLOWSTONE_GRPC_TOKEN=
# Wallet whose SOL and token balances to stream
WALLET_ADDRESS=

Save this as index.ts. It subscribes, decodes token accounts, drops stale writes, picks up new token accounts and closures, answers pings, and reconnects with capped exponential backoff and jitter.

import Client, { CommitmentLevel, type SubscribeRequest, type SubscribeUpdate } from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";

const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) {
    console.error(`Missing ${name} in .env`);
    process.exit(1);
  }
  return value;
}

const endpoint = required("YELLOWSTONE_GRPC_ENDPOINT");
const token = required("YELLOWSTONE_GRPC_TOKEN");
const wallet = required("WALLET_ADDRESS");

type Balance = { mint: string; amount: bigint; slot: bigint; writeVersion: bigint };
const balances = new Map<string, Balance>(); // account address -> latest state

function buildRequest(): SubscribeRequest {
  const known = [...balances.keys()].filter((address) => address !== wallet);
  return {
    accounts: {
      wallet: { account: [wallet], owner: [], filters: [] },
      spl: {
        account: [],
        owner: [TOKEN_PROGRAM],
        filters: [{ datasize: "165" }, { memcmp: { offset: "32", base58: wallet } }],
      },
      token2022: {
        account: [],
        owner: [TOKEN_2022_PROGRAM],
        filters: [{ tokenAccountState: true }, { memcmp: { offset: "32", base58: wallet } }],
      },
      // Closed accounts no longer match memcmp; watching them by address catches the close.
      ...(known.length ? { known: { account: known, owner: [], filters: [] } } : {}),
    },
    slots: {}, transactions: {}, transactionsStatus: {}, blocks: {}, blocksMeta: {},
    entry: {}, blockFooter: {}, accountsDataSlice: [],
    commitment: CommitmentLevel.CONFIRMED,
  };
}

function handle(update: SubscribeUpdate, resubscribe: () => void): void {
  const info = update.account?.account;
  if (!update.account || !info) return;
  const address = bs58.encode(info.pubkey);
  const owner = bs58.encode(info.owner);
  const slot = BigInt(update.account.slot);
  const writeVersion = BigInt(info.writeVersion);
  const signature = info.txnSignature ? bs58.encode(info.txnSignature) : "";

  const prev = balances.get(address);
  // Drop anything that is not strictly newer than what we already hold.
  if (prev && (slot < prev.slot || (slot === prev.slot && writeVersion <= prev.writeVersion))) return;

  let next: Balance | undefined;
  if (address === wallet) {
    next = { mint: "SOL", amount: BigInt(info.lamports), slot, writeVersion };
  } else if ((owner === TOKEN_PROGRAM || owner === TOKEN_2022_PROGRAM) && info.data.length >= 165) {
    const data = info.data;
    if (bs58.encode(data.subarray(32, 64)) === wallet) {
      const amount = new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(64, true);
      next = { mint: bs58.encode(data.subarray(0, 32)), amount, slot, writeVersion };
    }
  }

  if (!next) {
    if (prev) {
      balances.delete(address);
      console.log(`${slot} closed ${prev.mint} account ${address}: -${prev.amount} ${signature}`);
      resubscribe();
    }
    return;
  }
  balances.set(address, next);
  if (!prev) {
    console.log(`${slot} seen   ${next.mint} balance=${next.amount} ${signature}`);
    if (address !== wallet) resubscribe();
  } else if (next.amount !== prev.amount) {
    const delta = next.amount - prev.amount;
    console.log(`${slot} change ${next.mint} ${delta > 0n ? "+" : ""}${delta} -> ${next.amount} ${signature}`);
  }
}

let active: Awaited<ReturnType<Client["subscribe"]>> | undefined;
let stopping = false;

async function streamOnce(onHealthy: () => void): Promise<void> {
  const client = new Client(endpoint, token, undefined);
  await client.connect();
  const stream = await client.subscribe();
  active = stream;
  console.log("[grpc] connected");

  let pending: NodeJS.Timeout | undefined;
  // Each write replaces the whole filter set, so always send the full request. Debounce bursts.
  const resubscribe = () => {
    pending ??= setTimeout(() => {
      pending = undefined;
      stream.write(buildRequest());
    }, 500);
  };

  await new Promise<void>((resolve, reject) => {
    const closed = () => {
      clearTimeout(pending);
      stream.removeAllListeners();
      stream.on("error", () => {}); // a dying stream can emit a second error
      if (stopping) resolve();
      else reject(new Error("stream closed"));
    };
    stream.on("data", (update: SubscribeUpdate) => {
      onHealthy();
      if (update.ping) {
        stream.write({ ...buildRequest(), accounts: {}, ping: { id: 1 } });
        return;
      }
      if (!update.pong) handle(update, resubscribe);
    });
    stream.on("error", closed);
    stream.on("end", closed);
    stream.on("close", closed);
    stream.write(buildRequest());
  });
}

process.on("SIGINT", () => {
  stopping = true;
  active?.end();
  active?.destroy();
  console.log(`\nStopped with ${balances.size} tracked accounts`);
  process.exit(0);
});

let attempt = 0;
while (!stopping) {
  try {
    await streamOnce(() => (attempt = 0));
  } catch (error) {
    attempt++;
    const delay = Math.min(30_000, 500 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
    console.warn(`[grpc] ${error instanceof Error ? error.message : error}; reconnecting in ${Math.round(delay)} ms`);
    await new Promise((resolve) => setTimeout(resolve, delay));
  }
}

Amounts print in raw base units here. Divide by 10 ** decimals for display, and keep the arithmetic in bigint until then: a u64 does not fit in a JavaScript number.

Reading the account update

An account update has one populated field, account, of type SubscribeUpdateAccount:

type SubscribeUpdateAccount = {
  slot: string;              // u64 as string
  isStartup: boolean;        // true only for startup snapshots
  account?: {
    pubkey: Uint8Array;      // the account address
    owner: Uint8Array;       // owning program after this write
    lamports: string;        // u64 as string
    data: Uint8Array;        // full data, or your accountsDataSlice ranges
    executable: boolean;
    rentEpoch: string;
    writeVersion: string;    // orders writes to the same account within a slot
    txnSignature?: Uint8Array; // the transaction that caused the write
  };
};

Three fields carry most of the logic:

  • slot and writeVersion order writes. One account can be written several times in one slot, and a reconnect or an RPC snapshot can hand you state you already have. Keep the last (slot, writeVersion) per address and drop anything that is not strictly newer.
  • txnSignature links a balance change to its transaction. Fetch the transaction over RPC only when you need to know why the balance moved; most consumers never do.
  • owner tells you whether the account is still a token account. A write that leaves it owned by the System Program with empty data is a close.

Snapshot first, then stream

A subscription only sends writes that happen after it starts. Accounts that do not change while you are connected never appear, so a stream alone cannot tell you a wallet's current holdings. Take a snapshot over RPC once the subscription is live, and merge it with the same newer-wins rule:

const commitment = "confirmed"; // same level as the stream, or the merge compares unlike states
const result = await rpc("getTokenAccountsByOwner", [
  wallet,
  { programId: TOKEN_PROGRAM },
  { commitment, encoding: "jsonParsed" },
]);
for (const { pubkey, account } of result.value) {
  const { mint, tokenAmount } = account.data.parsed.info;
  // The snapshot is at result.context.slot; stream writes at a later slot win.
  record(pubkey, { mint, amount: BigInt(tokenAmount.amount), slot: BigInt(result.context.slot), writeVersion: 0n });
}

Run it for both token programs, and use getBalance for SOL. Subscribing before the snapshot means nothing falls between the two; overlap is harmless because the slot comparison discards it. The jsonParsed response also carries decimals, which fills your decimals cache for free.

Run the same snapshot after every reconnect. Writes made while you were disconnected are gone from the stream, and the snapshot shows the result. You learn the new balance but not each intermediate change. If you need every change, backfill the signatures for each account over RPC instead. The fromSlot replay field exists in the client, but treat it as optional and check subscribeReplayInfo() first, as the setup guide explains.

Snapshot calls are ordinary RPC traffic, so they count against your Solana RPC plan. getTokenAccountsByOwner on a wallet with thousands of accounts is a heavy call; run it on connect, not on a timer.

Production pitfalls

  • Filter updates replace everything. Writing a new SubscribeRequest swaps the whole filter set. Always rebuild the full request, debounce bursts of new accounts into one write, and resend the latest version on reconnect.
  • Busy accounts are loud. The wallet's own account changes on every fee it pays. A market maker's token accounts can change several times per slot. Handle each update in constant time and push heavy work, such as pricing or database writes, to a bounded queue.
  • Commitment changes what you see. At PROCESSED you see writes from slots that can still be dropped, so a balance can go up and then back down without any later transaction. Use CONFIRMED for user-facing balances and FINALIZED for accounting.
  • Do not use owner alone. An owner: [TOKEN_PROGRAM] filter without memcmp subscribes to every token account write on the network. Always pair it with the owner-field memcmp.
  • Large account lists. The known list grows with the wallet. For wallets with thousands of token accounts, or for many wallets, split them across named filters or connections, and check your plan's connection allowance on the Yellowstone gRPC page.
  • Reconnect without a snapshot. Without a backfill after reconnect, any change during the gap is lost until that account is written again, which for a dormant token can be never.

If your bots run next to the stream and you need consistent delivery under load, a dedicated node includes gRPC with no shared tenants.

FAQ

How do I subscribe to account changes with Yellowstone gRPC?

Add a named entry to the accounts map of your SubscribeRequest with account (addresses), owner (programs) and filters (datasize, memcmp, tokenAccountState, lamports). Each write to a matching account arrives as an update with account set.

How do I track all token balances of a wallet over gRPC?

Subscribe to the token programs as owners, with a memcmp on byte 32 equal to the wallet address. Add datasize: "165" for the classic program and tokenAccountState: true for Token-2022. Subscribe to the wallet address itself for SOL.

Why don't I get an update when a token account is closed?

A closed account has empty data and is owned by the System Program, so it stops matching owner and memcmp filters. Add the token accounts you already know to an address filter; the closing write matches that.

Does a Yellowstone account subscription send the current state on connect?

Not by default. It sends writes from the moment you subscribe. Take a snapshot with getTokenAccountsByOwner and getBalance after subscribing, and merge by slot.

What is writeVersion in a Yellowstone account update?

A counter that orders writes to accounts. Compare (slot, writeVersion) per address to discard duplicate or out-of-order updates after reconnects.

Is gRPC better than accountSubscribe over WebSocket?

For a few accounts, WebSocket accountSubscribe or a filtered programSubscribe is enough. gRPC fits when you want many named filters, address lists and transaction or slot streams on one connection, with the slot and writeVersion of every write.

References

  • Yellowstone gRPC account monitoring
  • Yellowstone gRPC quickstart
  • Reconnects and stream load
  • Solana Tracker documentation
  • @triton-one/yellowstone-grpc on npm
  • bs58 on npm

Companion project

The full example lives at solanatracker/examples/22-yellowstone-grpc-account-subscribe. It streams one wallet's SOL, SPL Token and Token-2022 balances, prints signed deltas with mint decimals, tracks closures, and, with SOLANA_RPC_URL set, takes a snapshot on connect and backfills after every reconnect.

cp .env.example .env && npm install && npm start
View source on GitHub›

Runnable Node.js project — clone from GitHub, add keys to .env, then npm start. gRPC examples use native dependencies — run locally with Node.js.

Related Guides

Yellowstone gRPC Tutorial: Connect, Filter, Reconnect
Infrastructure

Yellowstone gRPC Tutorial: Connect, Filter, Reconnect

Read more
Meteora DLMM Swap Parsing with Yellowstone gRPC
Meteora

Meteora DLMM Swap Parsing with Yellowstone gRPC

Read more
Pump.fun gRPC: Stream and Decode New Token Mints
Pump.fun

Pump.fun gRPC: Stream and Decode New Token Mints

Read more

Products

  • Data API
  • Pump.fun API
  • Solana RPC
  • Dedicated Nodes
  • Yellowstone gRPC
  • Raptor Swap API
  • Enterprise

Trading

  • Swap
  • Latest Tokens
  • Trending
  • Top Gainers
  • Memescope
  • Whale Watch
  • KOL Tracker

Tools

  • Wallet Tracker
  • Rugcheck
  • PnL Leaderboard
  • KOLScan
  • Axiom Leaderboard
  • Photon Leaderboard
  • Bloom Leaderboard
  • FOMO Leaderboard
  • GMGN Leaderboard
  • Pump.fun App Leaderboard
  • Terminal Leaderboard
  • Platform Compare
  • My Positions
  • Teams

Resources

  • Developer Guides
  • Blog
  • Documentation
  • API Reference
  • Status
  • Affiliate Program — 25% recurring, uncapped
Solana TrackerSolana Tracker© 2026
Terms of ServicePrivacy PolicyContact