To parse Raydium swaps from a live stream, subscribe to Yellowstone gRPC transactions that include the Raydium AMM v4 program, find every AMM v4 swap instruction (top-level or inner), and measure the fill from the pool vaults' token balance changes. The instruction data only tells you the trader's limits; the vault deltas tell you what actually moved. This guide shows the instruction tags, the account layout, a complete TypeScript parser and the cases that break naive parsers.
The stream setup (authentication, pings, reconnects, gap detection) is covered in the Yellowstone gRPC tutorial. Here the focus is decoding Raydium AMM v4 (675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8), the constant-product AMM behind many SOL and USDC pairs.
When raw parsing beats an indexed trade feed
Parsing swaps yourself makes sense when you need every fill the moment it lands, with routing detail (which aggregator called the pool) and no dependency on someone else's indexer. If you only need trades for a token, pool or wallet with USD values attached, the indexed Solana trades WebSocket on the Solana Data API is less work.
| Parse from Yellowstone gRPC | Indexed trade feed (Datastream) | |
|---|---|---|
| Coverage | Exactly the programs you decode | All indexed DEXes |
| Fill amounts | Raw integers from vault deltas | Parsed amounts with USD values |
| Routing detail | Calling program and instruction path | Not the focus |
| Commitment | You choose per subscription | Set by the indexer |
| Maintenance | Tags, layouts, edge cases | None |
Raydium AMM v4 swap instructions
AMM v4 is not an Anchor program. The first byte of instruction data is a tag, followed by two little-endian u64 arguments, so a swap is always 17 bytes. The bundled legacy IDL lists instructions in tag order, which gives tags 9 and 11. Tags 16 and 17 are newer than that IDL; in the Raydium AMM source they are SwapBaseInV2 and SwapBaseOutV2, which take the same arguments with a shorter account list that drops the OpenBook (Serum) accounts.
| Tag | Instruction | Accounts | Arguments |
|---|---|---|---|
| 9 | swapBaseIn | 18 or 17 | amountIn, minimumAmountOut |
| 11 | swapBaseOut | 18 or 17 | maxAmountIn, amountOut |
| 16 | swapBaseInV2 | 8 | amountIn, minimumAmountOut |
| 17 | swapBaseOutV2 | 8 | maxAmountIn, amountOut |
In the mainnet transactions we sampled in October 2026, tag 16 was the most common form. A parser that only knows the IDL's tags 9 and 11 misses most current swaps.
Three account positions hold in every form: index 0 is the SPL Token program, index 1 is the pool (amm), and index 2 is the AMM authority (5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1). The pool's two vaults are token accounts owned by that authority, which gives a layout-independent way to find them.
Measure the fill from vault balance deltas
amountIn and minimumAmountOut are bounds, not results. For a base-in swap the input is exact but the output can be anything above the minimum; for a base-out swap it is the other way round. To get both sides, use the transaction's preTokenBalances and postTokenBalances:
- Build the full key list: static
accountKeys, thenloadedWritableAddresses, thenloadedReadonlyAddresses. - Compute the change for every token account:
post - pre, treating a missing entry as zero. - Among the swap instruction's accounts, keep those whose token-balance
owneris the AMM authority and whose delta is non-zero. Those are the pool vaults. - The vault that grew received the trader's input; the vault that shrank paid out the output.
Owner matching works across the 18-, 17- and 8-account forms without hard-coding vault positions, and the deltas include the pool fee exactly as it settled.
Parse Raydium swaps in TypeScript
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=
# Optional AMM v4 pool to follow; empty parses every AMM v4 swap
POOL_ADDRESS=
# processed, confirmed or finalized
COMMITMENT=confirmed
import Client, { CommitmentLevel, type SubscribeRequest, type SubscribeUpdate } from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";
const RAYDIUM_AMM_V4 = "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8";
const SWAPS: Record<number, string> = { 9: "swapBaseIn", 11: "swapBaseOut", 16: "swapBaseInV2", 17: "swapBaseOutV2" };
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 pool = process.env.POOL_ADDRESS?.trim() || undefined;
if (pool && !isAddress(pool)) {
console.error(`POOL_ADDRESS is not a base58 Solana address: ${pool}`);
process.exit(1);
}
const LEVELS: Record<string, CommitmentLevel> = {
processed: CommitmentLevel.PROCESSED,
confirmed: CommitmentLevel.CONFIRMED,
finalized: CommitmentLevel.FINALIZED,
};
const commitment = LEVELS[(process.env.COMMITMENT?.trim() || "confirmed").toLowerCase()];
if (commitment === undefined) {
console.error("COMMITMENT must be processed, confirmed or finalized");
process.exit(1);
}
const empty = { accounts: {}, slots: {}, transactions: {}, transactionsStatus: {}, blocks: {}, blocksMeta: {}, entry: {}, blockFooter: {}, accountsDataSlice: [], commitment };
const subscription: SubscribeRequest = {
...empty,
transactions: {
raydium: pool
? { accountInclude: [pool], accountRequired: [RAYDIUM_AMM_V4], accountExclude: [], vote: false, failed: false }
: { accountInclude: [RAYDIUM_AMM_V4], accountRequired: [], accountExclude: [], vote: false, failed: false },
},
};
type TxInfo = NonNullable<NonNullable<SubscribeUpdate["transaction"]>["transaction"]>;
type Change = { mint: string; owner: string; decimals: number; delta: bigint };
function parse(info: TxInfo, slot: string): void {
const message = info.transaction?.message;
const meta = info.meta;
if (!message || !meta || meta.err) return; // failed swaps move nothing but the fee
const keys = [...message.accountKeys, ...meta.loadedWritableAddresses, ...meta.loadedReadonlyAddresses].map((k) => bs58.encode(k));
// Token balance change per account: post - pre, missing side = 0.
const changes = new Map<string, Change>();
for (const [side, list] of [[-1n, meta.preTokenBalances], [1n, meta.postTokenBalances]] as const) {
for (const b of list) {
const account = keys[b.accountIndex];
if (!account) continue;
const entry = changes.get(account) ?? { mint: b.mint, owner: b.owner, decimals: b.uiTokenAmount?.decimals ?? 0, delta: 0n };
entry.delta += side * BigInt(b.uiTokenAmount?.amount ?? "0");
changes.set(account, entry);
}
}
const inner = new Map(meta.innerInstructions.map((group) => [group.index, group.instructions]));
for (const [i, outer] of message.instructions.entries()) {
const outerProgram = keys[outer.programIdIndex];
const list = [{ ix: outer, path: `${i}` }, ...(inner.get(i) ?? []).map((ix, j) => ({ ix, path: `${i}.${j}` }))];
for (const { ix, path } of list) {
if (keys[ix.programIdIndex] !== RAYDIUM_AMM_V4 || ix.data.length !== 17) continue;
const kind = SWAPS[ix.data[0] ?? -1];
if (!kind) continue; // deposits, withdrawals, admin
const accounts = [...ix.accounts].map((index) => keys[index] ?? "");
const [, amm, authority] = accounts;
if (pool && amm !== pool) continue;
const vaults = accounts.map((a) => changes.get(a)).filter((c): c is Change => !!c && c.owner === authority && c.delta !== 0n);
const input = vaults.find((v) => v.delta > 0n);
const output = vaults.find((v) => v.delta < 0n);
const via = path.includes(".") ? outerProgram : "direct";
console.log(
[slot, kind, amm, input ? `${ui(input.delta, input.decimals)} ${input.mint}` : "?", "->",
output ? `${ui(-output.delta, output.decimals)} ${output.mint}` : "?", `via ${via}`, bs58.encode(info.signature)].join(" "),
);
}
}
}
const ui = (raw: bigint, decimals: number) => (Number(raw) / 10 ** decimals).toLocaleString("en-US", { maximumSignificantDigits: 6 });
let stopping = false;
let active: Awaited<ReturnType<Client["subscribe"]>> | undefined;
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");
await new Promise<void>((resolve, reject) => {
const closed = () => (stopping ? resolve() : reject(new Error("stream closed by server")));
stream.on("data", (update: SubscribeUpdate) => {
onHealthy();
if (update.ping) return void stream.write({ ...empty, ping: { id: 1 } });
const tx = update.transaction;
if (tx?.transaction) parse(tx.transaction, tx.slot);
});
stream.on("error", reject);
stream.on("end", closed);
stream.on("close", closed);
stream.write(subscription);
});
}
process.on("SIGINT", () => {
stopping = true;
active?.destroy();
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));
}
}
function isAddress(value: string): boolean {
try {
return bs58.decode(value).length === 32;
} catch {
return false;
}
}
With POOL_ADDRESS set, the filter requires both the pool and the program, so the server only sends transactions that touch that pool.
The parsed swap shape
The companion project returns one object per swap instruction and formats it into a table. Its type:
type RaydiumSwap = {
signature: string;
slot: string; // u64 as string
path: string; // "2" top-level, "3.1" inner instruction 1 under top-level 3
viaProgram: string | null; // calling program for inner swaps (aggregator, bot)
pool: string; // amm id, account 1
kind: "swapBaseIn" | "swapBaseOut" | "swapBaseInV2" | "swapBaseOutV2";
args: { amountIn: bigint; minimumAmountOut: bigint } | { maxAmountIn: bigint; amountOut: bigint };
input?: { mint: string; raw: bigint; decimals: number }; // vault that grew
output?: { mint: string; raw: bigint; decimals: number }; // vault that shrank
poolSwapsInTx: number; // > 1 means input/output are a net across several swaps
feePayer: string;
};
Price falls out of the two legs: when one side is wrapped SOL (So11111111111111111111111111111111111111112), SOL per token is inputUi / outputUi or the inverse.
Production pitfalls
Aggregator routes. Most swaps do not call Raydium directly. Jupiter, other routers and trading bots invoke AMM v4 by CPI, so the swap appears in innerInstructions. Parsers that only read top-level instructions miss them. Record the calling program; it is useful for routing analysis.
Transactions that list the program but never call it. A filter on the program key matches every transaction whose key list includes it. Many bot transactions include Raydium in their accounts and then do nothing with it. Count swaps after decoding, not matched transactions.
Multiple swaps per transaction. Arbitrage routes can hit the same pool twice, or several pools in one transaction. Vault deltas are per transaction, so two swaps on the same pool share one net delta. The example flags those rows as net; if you need per-hop amounts, use the instruction arguments plus a pool reserve model, or the SPL Token transfer inner instructions under each swap.
Failed transactions. A failed swap still pays a fee and still shows up if you set failed: true or omit the flag. Check meta.err before parsing.
Lookup tables. Versioned transactions load accounts from address lookup tables. Without appending loadedWritableAddresses and loadedReadonlyAddresses to the key list, account indexes past the static keys resolve to nothing or to the wrong account.
Commitment. Processed swaps can be rolled back with their slot. Use confirmed for trade logs and analytics, or reconcile processed rows later.
Other Raydium programs. CPMM and CLMM pools are separate programs with Anchor layouts. This parser covers AMM v4 only. For another venue, see Meteora DLMM swap parsing.
FAQ
How do I parse Raydium swaps for a single pool?
Set POOL_ADDRESS to the pool's amm id. The subscription then uses accountInclude: [pool] with accountRequired: [RAYDIUM_AMM_V4], and the parser skips swaps on other pools in the same transaction.
Why not trust amountIn and minimumAmountOut?
They are the limits the trader signed. The actual output depends on pool state at execution, so read it from the vault balance changes.
What are tags 16 and 17?
SwapBaseInV2 and SwapBaseOutV2: the same swap arguments with an 8-account list that skips the OpenBook market accounts. They are not in the older bundled IDL, so handle them explicitly.
Can a swap have no vault deltas?
Rarely, if token balance metadata is missing. The example prints those rows as unattributed rather than guessing amounts.
Which commitment should a swap parser use?
Confirmed for most uses. Processed is faster but can include swaps from slots that are later skipped.
References
- Transaction monitoring with Yellowstone gRPC
- Yellowstone gRPC quickstart
- Reconnects and stream load
- @triton-one/yellowstone-grpc on npm
- bs58 on npm
Companion project
The full example lives at solanatracker/examples/09-raydium-stream-and-parse-amm-transactions. It bundles the Raydium AMM v4 IDL, derives tags 9 and 11 from it, formats amounts with bigint precision, labels common quote mints, flags net rows and prints a summary on Ctrl+C.
cp .env.example .env && npm install && npm start