A useful Yellowstone gRPC codebase shares most of its code between bots. Whether you stream swaps, new pools, launches or a single wallet, every transaction goes through the same steps: subscribe, flatten instructions into a call tree, decode them against a program IDL, then turn the result into a trade or a pool. This cookbook is 18 TypeScript recipes on one shared core. It decodes 13 Solana DEX and launchpad programs with no Anchor dependency, and adding a venue means one file.
The recipes run on Solana Tracker Yellowstone gRPC. The same endpoint ships with Business and Professional Solana RPC plans and with every dedicated node. If you've never opened a stream, read the Yellowstone gRPC tutorial first. This guide assumes you know how pings, filters and reconnects work, and spends its time on what comes after the bytes arrive.
The recipes
Each recipe is a single command, and each one is a small function, because the shared core does the work.
| Group | Recipe | What it streams |
|---|---|---|
| Connection | slots [seconds] | Every slot status change, from first shred to finalized or dead |
latency [venues] | Delivery delay from the server's createdAt to your process, as percentiles | |
reconnect [venues] | Resumes from the last seen slot after a drop and skips duplicates | |
filters [venues] | Changes the subscription on a live stream: type +venue or -venue | |
| Transactions | transactions | Every transaction touching the given accounts |
token | Per-wallet balance changes for one token | |
| Trades | trades [venues] | Every swap, decoded into side, amounts and price |
wallet | Swaps signed by specific wallets, the core of a copy-trading bot | |
pool | Swaps against specific pools or bonding curves | |
price | Every fill for one token, with its execution price | |
top [venues] | Most traded tokens over a rolling 60-second window | |
| Lifecycle | pools [venues] | New liquidity pools |
launches [venues] | New tokens on launchpad bonding curves | |
migrations [venues] | Bonding curves that graduated to an AMM | |
| Accounts | accounts | Live program account state, decoded with the IDL |
curves | Pump bonding curve progress milestones | |
| Tools | notify [venues] | Telegram alerts for new pools and launches |
decode | One confirmed transaction fetched over RPC and replayed through the matching decoders |
Venues: pump, pump-amm, raydium-amm-v4, raydium-clmm, raydium-cpmm, raydium-launchlab, meteora-dlmm, meteora-damm-v1, meteora-damm-v2, meteora-dbc, orca-whirlpool, moonshot and fluxbeam. A [venues] argument takes a comma-separated list or all. latency, reconnect and filters default to pump-amm; the other recipes default to every venue.
Parse once, decode many times
The first design decision is the most important one: a transaction is parsed exactly once, and every decoder reads the parsed result. A stream that watches five venues should not flatten the same inner instructions five times, and a decoder should never touch protobuf types.
gRPC update ─▶ parseTx() ─▶ ParsedTx ─┬─▶ pump.trades(tx)
├─▶ raydiumCpmm.trades(tx)
└─▶ meteoraDlmm.pools(tx) ...
watchTransactions is the only place the transaction recipes touch the wire:
export function watchTransactions(filter: TxFilter, onTx: (tx: ParsedTx) => void) {
const request = { ...emptyRequest(), commitment: commitment(), transactions: { txs: txFilter(filter) } };
const stream = runStream({
request,
onUpdate: (update) => {
const t = update.transaction;
if (t?.transaction) onTx(parseTx(t.transaction, t.slot));
},
});
onShutdown(async () => {
stream.stop();
await stream.done;
});
return stream;
}
txFilter fills in what is easy to forget: vote: false, failed: false, and empty include, exclude and required lists. runStream owns the ping loop and the reconnect backoff, so a recipe never has to handle a dropped connection. It resubscribes at the chain head, though, so anything sent during the drop is missed; the reconnect recipe below shows how to recover that gap.
ParsedTx is the contract between the two halves:
| Field | Contents |
|---|---|
signature, slot | Base58 signature, slot as a string |
signer | Fee payer, the first account key |
failed | true when meta.err is set. The stream filter excludes failed transactions by default |
instructions | Every instruction, outer and inner, with a call-tree path |
balances | Token balance changes per owner and mint, from pre and post token balances |
transfers | SPL Token and Token-2022 transfers, each with its parent instruction path |
events | Raw Anchor events from self-CPI and from Program data: logs |
logs | The transaction's log messages |
decimals(mint) | Mint decimals as seen in the transaction's token balances |
With that in place, the trades recipe takes a few lines. It subscribes to the program ids of the chosen venues and asks each decoder for trades:
export async function trades(args: string[]) {
const protocols = selectProtocols(args[0]);
watchTransactions({ accountInclude: protocols.map((p) => p.programId) }, (tx) => {
for (const p of protocols) for (const trade of p.trades(tx)) console.log(tradeLine(trade, tx));
});
}
wallet and pool are the same loop with a different filter. wallet subscribes with the wallets in accountInclude and keeps a trade when its trader or the fee payer is in the set. pool subscribes on the pool addresses and keeps a trade when trade.pool matches. Filtering on the server and then checking again on the client matters, because a transaction that only sends your wallet SOL, or creates a token account for it, still matches accountInclude.
Call-tree paths
Solana gives you outer instructions plus a flat list of inner instructions per outer index, each with a stackHeight. Most parsers stop there and lose the parent of every cross-program invocation (CPI). The cookbook rebuilds the tree and gives every instruction a path: 3 is the fourth outer instruction, 3.0 is its first CPI, and 3.1.0 is the first CPI made by that instruction's second CPI.
for (const [i, ix] of outer.entries()) {
result.push({ ...resolve(ix, keys), path: `${i}`, depth: 1, parent: undefined });
// stack[d] = path of the most recent instruction at depth d, childCount[path] = CPIs made so far.
const stack: string[] = [];
stack[1] = `${i}`;
const childCount = new Map<string, number>();
for (const innerIx of innerByIndex.get(i) ?? []) {
const depth = Math.max(2, innerIx.stackHeight ?? 2);
const parent = stack[depth - 1] ?? stack[1];
const n = childCount.get(parent) ?? 0;
childCount.set(parent, n + 1);
const path = `${parent}.${n}`;
stack[depth] = path;
stack.length = depth + 1;
result.push({ ...resolve(innerIx, keys), path, depth, parent });
}
}
Paths answer the questions every decoder asks:
- Which transfers belong to this swap? The ones whose
parentis the swap's path. An aggregator route through three pools makes six transfers, and paths split them correctly. - Is this instruction inside that one?
isUnder(path, ancestor)is just a prefix check onancestor + ".". - Which instruction emitted this event? The event carries the path of its emitter, covered below.
Paths are also the right deduplication key once you store trades. One transaction can hold several swaps on the same venue, so signature alone is not unique. signature plus path is.
Account keys come from the static keys followed by meta.loadedWritableAddresses and then meta.loadedReadonlyAddresses. Skip the loaded addresses and every V0 transaction that uses a lookup table decodes against the wrong accounts.
A borsh decoder that reads IDLs directly
The cookbook does not depend on Anchor. src/lib/idl.ts is a few hundred lines that read an IDL and decode instructions, events and accounts, and it takes both IDL formats you'll find in the wild:
| Anchor 0.30+ IDL | Legacy IDL | |
|---|---|---|
| Names | snake_case | camelCase |
| Discriminators | Explicit discriminator byte arrays | Derived: sha256("global: |
| Public key type | pubkey | publicKey |
| User types | { "defined": { "name": "X" } } | { "defined": "X" } |
Everything is normalized to camelCase on the way in. Decoders always write ix.accounts.poolState and e.data.inputAmount no matter which format the IDL file uses.
Two programs here are not Anchor programs at all. Raydium AMM v4 and FluxBeam (an SPL token-swap fork) tag instructions with a single byte, not eight. Their IDL files set "discriminator": "u8-index" at the top level, and the decoder then uses each instruction's position in the list as its tag:
const indexTagged = idl.discriminator === "u8-index";
for (const [i, ix] of idl.instructions.entries()) {
const disc = indexTagged ? [i] : (ix.discriminator ?? [...sighash(`global:${snake(ix.name)}`)]);
// ...
}
A decoded instruction has name, args, named accounts (zipped from the IDL's account list) and remaining for any extra accounts. Integers up to 32 bits decode to number. 64-bit and wider decode to bigint, so lamport and token amounts never lose precision.
Events and instructions, paired by path
Anchor programs emit events in two ways, and a decoder has to read both:
emit_cpi!: the program invokes itself with data that starts with the 8-byte event tage445a52e51cb9a1d. This survives log truncation and is the reliable source. The event's path is set to the parent instruction's path, the one that did the work.emit!: aProgram data:log line. The cookbook replays theinvokeandsuccess/failedlog lines against the instruction list to find which instruction was running when the line was written.
Because events and instructions share paths, a decoder can pair them with no guessing. This is the complete Raydium CPMM decoder:
trades(tx) {
const d = decode(tx, coder);
return d.eventsNamed("SwapEvent").map((e) => {
const ix = d.ixAt(e.path);
return {
...base(tx, "raydium-cpmm"),
label: ix?.name ?? "swap",
path: e.path,
trader: ix?.accounts.payer ?? tx.signer,
pool: str(e.data.poolId),
inputMint: str(e.data.inputMint) || (ix?.accounts.inputTokenMint ?? ""),
inputAmount: big(e.data.inputAmount),
outputMint: str(e.data.outputMint) || (ix?.accounts.outputTokenMint ?? ""),
outputAmount: big(e.data.outputAmount),
};
});
},
The event supplies the amounts that actually moved. The instruction at the same path supplies the trader and serves as the fallback for the mints. When a bot routes through CPMM, payer is the bot's own signer, not the aggregator program, which is exactly the wallet a copy-trading bot wants to follow.
Programs that emit no events need a different approach. Raydium AMM v4 makes exactly two token transfers per swap: the trader's leg into the pool and the pool's leg out, signed by the AMM authority, a program-derived address (PDA). The decoder finds the swap instruction, takes the transfers whose parent is that path, and splits them by authority. This works whether the caller used the 17-account or the 18-account variant, which trips up decoders that read accounts by position. The Raydium streaming guide and the Meteora DLMM parsing guide go deeper into single venues.
From a swap to a trade line
Every decoder returns the same Trade: venue, signature, slot, label, path, trader, pool, the input mint and amount, and the output mint and amount. Amounts are raw bigint base units. Buy and sell are not decoder concerns, because "buy" depends on which side you treat as money.
orient decides that once for all venues. It ranks quote mints USDC above USDT above SOL. The side with the higher rank is the quote, so paying SOL for a token is a buy:
export function orient(trade: Trade, decimals: (mint: string) => number | undefined) {
const inRank = QUOTE_RANK.get(trade.inputMint) ?? 0;
const outRank = QUOTE_RANK.get(trade.outputMint) ?? 0;
if (inRank === 0 && outRank === 0) return undefined;
const buy = inRank > outRank;
const [token, tokenRaw, quote, quoteRaw] = buy
? [trade.outputMint, trade.outputAmount, trade.inputMint, trade.inputAmount]
: [trade.inputMint, trade.inputAmount, trade.outputMint, trade.outputAmount];
// ...scale both sides by decimals, price = quoteAmount / tokenAmount
}
A token-to-token swap returns undefined, and the trade line prints raw amounts without a side. That's on purpose: guessing a direction for a swap between two meme tokens produces a price that means nothing. In a USDC/SOL swap, SOL is the token and USDC the quote, because USDC ranks higher.
Decimals come from the transaction's own token balances (tx.decimals(mint)), so no RPC call is needed per trade. A mint that only appears in instruction data, never in a balance, returns undefined. The trade line then shows raw units rather than a wrong number.
Know what each venue's amounts include
Different programs report amounts differently, and a cross-venue volume number is only correct if you account for it. The decoders keep each program's own convention and document it:
| Venue | inputAmount / outputAmount mean |
|---|---|
| Pump bonding curve | Curve-side SOL and token amounts, before fees |
| PumpSwap AMM | Pool-side amounts before LP, protocol and creator fees. The event has the fee fields if you want the trader's total |
| Meteora DBC, DAMM v2 | Input includes the fee (includedFeeInputAmount) |
| FluxBeam | Actual token transfers, not instruction args. Token-2022 transfer fees make the two differ |
| Raydium AMM v4 | The two vault transfers: what really moved |
For volume, pick one convention and adjust per venue. For copy trading, you usually want what the trader paid, so add fees back where the venue excludes them.
Add a venue
Adding a venue takes three steps, and every recipe picks it up without further changes:
- Drop the IDL in
idl/. Either format works. For a non-Anchor program with 1-byte tags, add"discriminator": "u8-index". - Write a decoder in
src/protocols/that returns a.ts Protocol:id,label,programId,coder,trades(tx)and, when it applies,pools,launchesandmigrations. Usedecode(tx, coder)foreventsNamed,ixsNamedandixAt(path). Usemoved(tx, path, { from, to })to sum the transfers under an instruction when a program has no event. - Register it in
PROTOCOLSinsrc/protocols/index.ts.
selectProtocols throws on an unknown id, so a typo in a venue list fails at startup instead of quietly streaming nothing.
Test a decoder against a real transaction
Waiting for a live swap is a slow way to debug a decoder. The decode recipe fetches one confirmed transaction over JSON-RPC and replays it through every decoder:
npm start -- decode <signature>
fetchTransaction calls getTransaction with encoding: "json" and maxSupportedTransactionVersion: 1 (so version-1 transactions replay as well as legacy and v0), retries 429s, then reshapes the response into the same SubscribeUpdateTransactionInfo the stream delivers. Base58 keys become bytes, the loaded addresses go where gRPC puts them, and inner instructions keep their stackHeight. parseTx can't tell the difference, so a transaction that decodes correctly here decodes correctly live.
── PumpSwap AMM (pAMM…fXEA)
ix 3 buyExactQuoteIn { spendableQuoteIn: 278839, minBaseAmountOut: 355277463, trackVolume: [true] }
ev 3 BuyEvent { baseAmountOut: 365405021, quoteAmountIn: 278839, lpFee: 552, protocolFee: 138, … }
── What the stream recipes print
10:13:48 pump-amm buy 365.405021 iSzq…pump for 0.000278 SOL @ 7.631e-7 SOL 2qa2…zLiT 4oSYdW…rZkfpz
The same function makes a backfill: fetch recent signatures for an address, replay each one, then start the stream.
Resume after a drop without double-counting
Reconnecting is the easy part, and runStream already does it with backoff. Recovering what you missed takes more care. Yellowstone accepts fromSlot on a subscribe request and replays updates from that slot, within the server's retention window. The reconnect recipe tracks the highest slot it has seen and asks for it again on every reconnect:
const stream = runStream({
request: (isReconnect) => ({
...emptyRequest(),
commitment: commitment(),
transactions: { txs: txFilter({ accountInclude: [protocol.programId] }) },
...(isReconnect && lastSlot !== undefined ? { fromSlot: lastSlot.toString() } : {}),
}),
onUpdate: (update) => {
const t = update.transaction;
if (!t?.transaction) return;
const tx = parseTx(t.transaction, t.slot);
const slot = BigInt(t.slot);
if (lastSlot === undefined || slot > lastSlot) lastSlot = slot;
if (seen.has(tx.signature)) return;
remember(tx.signature);
for (const trade of protocol.trades(tx)) console.log(tradeLine(trade, tx));
},
});
It resumes from lastSlot, not lastSlot + 1, because a slot's transactions arrive over time and the drop may have come halfway through one. The replay therefore overlaps with what you already handled, and the seen set of signatures drops the duplicates. The set is capped: once it passes 50,000 entries, the oldest 10,000 go. A Set iterates in insertion order, so trimming from the front removes the oldest.
The request is a function of isReconnect. The first connect starts at the head of the chain and every reconnect resumes. If the outage outlasts the retention window, fromSlot is rejected or returns a gap, and the only complete recovery is an RPC backfill.
Change filters on a live stream
A Yellowstone subscription is a bidirectional stream. Writing a new SubscribeRequest to it replaces the entire filter set, with no reconnect, no new handshake and no gap. The filters recipe rebuilds the request from a set of active venues every time you type +venue or -venue:
const request = (): SubscribeRequest => ({
...emptyRequest(),
commitment: commitment(),
// An empty filter map means "nothing": the stream stays open and only pings flow.
transactions: active.size ? { txs: txFilter({ accountInclude: [...active.values()].map((p) => p.programId) }) } : {},
});
input.on("line", (line) => {
// ...update `active` from +venue / -venue
stream.update(request());
});
Since the write replaces rather than merges, always send the full desired state. A request that only names the new venue drops all the others. runStream also keeps the latest request, so a reconnect after an update subscribes to the current filters, not the ones you started with.
This is how a wallet tracker should add and remove wallets: one stream whose accountInclude list changes, not one stream per wallet.
Commitment and latency
GRPC_COMMITMENT selects processed (the default), confirmed or finalized:
| Commitment | Typical delay after the leader | Risk |
|---|---|---|
processed | Lowest | A transaction on a fork that is later dropped can appear and never land |
confirmed | About 400 ms to a second more | Supermajority voted, practically final |
finalized | About 13 seconds more | None |
Signal bots and copy traders run on processed, because by the time a trade is confirmed the price has moved. Anything that writes to a ledger (PnL, volume, accounting) should use confirmed, or record processed updates as provisional and reconcile them later.
The latency recipe measures delivery directly. Each update carries createdAt, the server time when it was produced. The recipe compares that to Date.now() and prints p50, p90 and p99 every 10 seconds:
onUpdate: (update) => {
if (update.transaction && update.createdAt) samples.push(Date.now() - update.createdAt.getTime());
},
Two caveats. The number is only as accurate as the clock sync between you and the server, so run NTP before you trust absolute values. It also measures delivery, not processing: if a heavy onUpdate handler falls behind, the queue grows inside your process, and this number won't show it. Measure both.
Run the cookbook
git clone https://github.com/solanatracker/examples.git
cd examples/25-yellowstone-grpc-examples
cp .env.example .env # YELLOWSTONE_GRPC_ENDPOINT, YELLOWSTONE_GRPC_TOKEN
npm install
npm start -- trades pump-amm,raydium-cpmm
npm start with no arguments lists every recipe. decode also needs SOLANA_RPC_URL, and notify reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID, falling back to printing when they're unset. For a deeper look at new token launches, see the Pump.fun mint streaming guide.
Production pitfalls
- One stream per bot, not per filter. Every venue a bot watches fits in one
accountIncludelist. Opening one stream per wallet or pool burns connection limits and multiplies duplicate updates. - Key stored trades by
signatureandpath. A reconnect replays, and a transaction can hold several swaps. Signature alone either double-counts or drops trades. - Don't trust
accountIncludealone. It matches any transaction that touches the account, including ones where a wallet only receives a transfer. Check the trader or pool on the decoded trade. - Watch the handler, not just the socket. gRPC delivers as fast as you read. A slow
onUpdate(a database write, an HTTP call) builds backpressure until the server drops you. Queue the heavy work and keep the handler synchronous. - Expect logs to be truncated. Very large transactions cut off their logs, and
Program data:events are lost with them. Prefer self-CPI events where the program emits them, and fall back to transfers. failed: falseonly filters on the server. Replays fromdecodeinclude failed transactions. Checktx.failedbefore counting volume.- Processed is provisional. If you act on processed updates, assume some will never land, and confirm before you settle balances.
- Keep IDLs in step with programs. When a program upgrades and adds an instruction, the old IDL decodes the old ones and silently skips the new one. Watch for unknown discriminators on venues you care about.
FAQ
Does this need Anchor or @coral-xyz/anchor?
No. The decoder reads IDL JSON and decodes borsh itself, which keeps the dependency list at @triton-one/yellowstone-grpc and bs58. It accepts both the current and the legacy Anchor IDL formats.
Why decode events instead of balance changes?
Events carry the amounts the program actually used, including fees, pool ids and reserves, and they attribute each swap to its own instruction. Balance changes are net per transaction, so a multi-hop route or an arbitrage collapses into one number. The cookbook falls back to transfers only for programs that emit no events.
How do I follow a wallet that trades through an aggregator?
Run wallet . The filter matches any transaction the wallet touches. Each decoder takes the trader from the swap's own event or accounts (the user, payer or transfer authority), which is the wallet even when an aggregator program makes the CPI. The recipe also keeps any trade in a transaction the wallet signed.
Can I get trades for one token rather than one venue?
Yes, price subscribes on the mint and prints every fill across all decoded venues with its execution price. token shows raw balance changes per wallet, including transfers that are not trades.
What happens to trades during a disconnect?
reconnect resumes with fromSlot and drops duplicates by signature, so nothing is lost while the server still has those slots. The other recipes resume at the chain head. After a longer outage, reconnect drops fromSlot and warns; backfill with fetchTransaction over RPC.
Why do my volume numbers differ from a dashboard's?
Usually fees. Venues disagree on whether amounts include them, so check the conventions table and normalize. The other common cause is counting failed or processed-but-dropped transactions.
How many venues can one stream watch?
All 13 in this cookbook fit in one subscription with trades all. The limit you'll hit first is your handler's throughput, not the filter size.
Is the same code usable in a backend service?
Yes. Take src/lib/ and the decoders you need, call watchTransactions from your service, and replace console.log with your queue or database writes.
References
- Yellowstone gRPC overview
- Yellowstone gRPC quickstart
- Transaction monitoring
- Slot and block monitoring
- Reconnects and stream load
- @triton-one/yellowstone-grpc on npm
- bs58 on npm
Companion project
The full cookbook lives at solanatracker/examples/25-yellowstone-grpc-examples. It contains the 18 recipes, the shared parser and IDL decoder, 13 venue decoders with their IDLs, and the RPC replay tool. To build a trading bot on top of the stream, send the swap through the Raptor swap API.