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.
| Approach | What you receive | You still have to | Good fit |
|---|---|---|---|
| gRPC account filter | Full account data after each write, with slot, writeVersion and signature | Decode the account layout, track previous values | Balances, pool reserves, program state, many accounts per wallet |
| gRPC transaction filter | Full transaction with pre and post token balances | Find the right balance entry per account and handle inner instructions | Swap and transfer history, PnL, copy trading |
RPC WebSocket (accountSubscribe, programSubscribe) | One notification stream per subscription; programSubscribe accepts memcmp and dataSize filters | Manage one subscription per address or filter, resubscribe after every reconnect | A handful of accounts, light workloads |
| Datastream wallet balance room | Indexed balance updates for a wallet (amounts, no prices) | Nothing; already parsed. Premium plan or higher | Dashboards 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.
| Field | Type | Matches | |||
|---|---|---|---|---|---|
account | string[] (base58) | These exact addresses | |||
owner | string[] (base58) | Accounts owned by any of these programs | |||
filters[].datasize | string | Accounts whose data is exactly this many bytes | |||
filters[].memcmp | `{ offset: string; base58 \ | base64 \ | bytes }` | Accounts with these bytes at this offset | |
filters[].tokenAccountState | boolean | Accounts whose data parses as a token account | |||
filters[].lamports | `{ eq \ | ne \ | lt \ | gt: string }` | Accounts by lamport balance |
nonemptyTxnSignature | boolean (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.
| Bytes | Field | Notes |
|---|---|---|
| 0–31 | mint | The token this account holds |
| 32–63 | owner | The wallet that controls it; the memcmp target |
| 64–71 | amount | Raw amount, u64 little-endian, in base units |
| 72–164 | delegate, state, native flag, delegated amount, close authority | Rarely 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:
slotandwriteVersionorder 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.txnSignaturelinks 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.ownertells 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
SubscribeRequestswaps 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
PROCESSEDyou see writes from slots that can still be dropped, so a balance can go up and then back down without any later transaction. UseCONFIRMEDfor user-facing balances andFINALIZEDfor accounting. - Do not use
owneralone. Anowner: [TOKEN_PROGRAM]filter withoutmemcmpsubscribes to every token account write on the network. Always pair it with the owner-fieldmemcmp. - Large
accountlists. Theknownlist 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