Meteora DLMM swap parsing comes down to two steps: identify the swap instruction by its 8-byte Anchor discriminator, then read the executed amounts, fee and bin range from the Swap event the program emits as a self-CPI in the same transaction. With a Yellowstone gRPC transaction stream filtered on the DLMM program, that gives you every fill in real time, including the large share routed through aggregators.
This guide covers the instruction variants and how to compute their discriminators, the Swap event layout, how bin IDs map to prices, a complete TypeScript parser and the pitfalls seen on mainnet. Connection handling (authentication, pings, reconnects, gaps) is explained in the Yellowstone gRPC tutorial.
When to parse DLMM swaps yourself
Meteora DLMM (LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo) is a liquidity-book AMM: liquidity sits in discrete price bins, and a swap walks from the active bin into neighbouring bins until it is filled. Parsing the raw transactions gives you information an indexed feed usually flattens: which bins a swap crossed, the exact fee split, and which program routed it.
| Need | Parse from Yellowstone gRPC | Indexed trades (Datastream) |
|---|---|---|
| Executed amounts | Raw integers from the Swap event | Parsed amounts with USD values |
| Bin range per swap | startBinId, endBinId | Not exposed |
| Fee and protocol fee | Per swap, raw | Not the focus |
| Other DEXes | Only what you decode | All indexed venues |
| Maintenance | Discriminators, layouts, IDL updates | None |
If you need USD-denominated trades across venues, the Solana trades WebSocket is less work. If you also follow Raydium, pair this with the Raydium AMM v4 swap parser; routed trades often split across both.
Swap instructions and their discriminators
DLMM is an Anchor program, but the bundled IDL (version 0.8.2) uses the legacy format without discriminator fields. Anchor derives them the same way for every instruction: the first 8 bytes of sha256("global:. Computing them with node:crypto avoids a dependency on a full Anchor coder and on the IDL format.
| Instruction | Preimage | Discriminator (hex) | Arguments |
|---|---|---|---|
swap | global:swap | f8c69e91e17587c8 | amountIn, minAmountOut |
swap2 | global:swap2 | 414b3f4ceb5b5b88 | amountIn, minAmountOut |
swapExactOut | global:swap_exact_out | fa49652126cf4bb8 | maxInAmount, outAmount |
swapWithPriceImpact | global:swap_with_price_impact | 38ade6d0ade49ccd | amountIn, activeId, maxPriceImpactBps |
All four share the leading account layout: lbPair at 0, binArrayBitmapExtension at 1, reserveX at 2, reserveY at 3, user token accounts at 4 and 5, tokenXMint at 6, tokenYMint at 7, oracle at 8, hostFeeIn at 9 and user at 10. swap2 adds a memo program before the event authority. The companion project looks these positions up by name in the IDL, normalizing lbPair and lb_pair because the IDL spells them both ways.
In every DLMM swap we sampled on mainnet in October 2026, the instruction was swap2 and it arrived by CPI from an aggregator or trading program, not as a top-level instruction. A parser that reads only top-level instructions, or only knows swap, sees almost nothing.
Decode the Swap event
Instruction arguments are limits. The fill is in the Swap event, which DLMM emits through Anchor's event CPI: the program invokes itself through its event authority PDA (D1ZN9Wj1fRSUQfCjhvnu1hqDMT7hzjzBBpi12nVniYD6), and the inner instruction's data carries the event. That makes it independent of log truncation.
The inner instruction data is laid out as:
| Bytes | Content |
|---|---|
| 0-7 | e445a52e51cb9a1d (Anchor EVENT_IX_TAG, little-endian) |
| 8-15 | 516ce3becdd00ac4 = sha256("event:Swap")[0..8] |
| 16-47 | lbPair (pubkey) |
| 48-79 | from (pubkey) |
| 80-83 | startBinId (i32) |
| 84-87 | endBinId (i32) |
| 88-95 | amountIn (u64) |
| 96-103 | amountOut (u64) |
| 104 | swapForY (bool) |
| 105-112 | fee (u64) |
| 113-120 | protocolFee (u64) |
| 121-136 | feeBps (u128) |
| 137-144 | hostFee (u64) |
swapForY: true means the trader sold token X for token Y. amountIn includes the fee: in the swaps we checked, the bin price reproduced the effective price only after subtracting fee from amountIn.
Bins and prices
Each bin has a fixed price set by the pair's binStep (in basis points) and the bin ID:
price(binId) = (1 + binStep / 10000) ^ binId // token Y atoms per token X atom
uiPrice = price(binId) * 10 ^ (decimalsX - decimalsY)
binStep is not in the transaction; read it from the LbPair account (an unsigned 16-bit integer at byte offset 80, next to the active bin ID, a signed 32-bit integer at offset 76), once per pair, and cache it. A swap where startBinId equals endBinId filled inside one bin at one price. A swap that crossed bins filled at several prices, and endBinId is the active bin it left behind. Negative bin IDs are normal; they mean a price below 1 in raw units.
Meteora DLMM swap parsing 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 DLMM LB pair address to follow; empty parses every DLMM swap
POOL_ADDRESS=
# processed, confirmed or finalized
COMMITMENT=confirmed
import { createHash } from "node:crypto";
import Client, { CommitmentLevel, type SubscribeRequest, type SubscribeUpdate } from "@triton-one/yellowstone-grpc";
import bs58 from "bs58";
const METEORA_DLMM = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo";
const sighash = (preimage: string) => createHash("sha256").update(preimage).digest().subarray(0, 8);
const SWAPS = ["swap", "swap2", "swap_exact_out", "swap_with_price_impact"].map((name) => ({ name, disc: sighash(`global:${name}`) }));
const EVENT_PREFIX = Buffer.concat([Buffer.from("e445a52e51cb9a1d", "hex"), sighash("event:Swap")]);
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 pair = process.env.POOL_ADDRESS?.trim() || undefined;
if (pair && !isAddress(pair)) {
console.error(`POOL_ADDRESS is not a base58 Solana address: ${pair}`);
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: {
dlmm: pair
? { accountInclude: [pair], accountRequired: [METEORA_DLMM], accountExclude: [], vote: false, failed: false }
: { accountInclude: [METEORA_DLMM], accountRequired: [], accountExclude: [], vote: false, failed: false },
},
};
type TxInfo = NonNullable<NonNullable<SubscribeUpdate["transaction"]>["transaction"]>;
function parse(info: TxInfo, slot: string): void {
const message = info.transaction?.message;
const meta = info.meta;
if (!message || !meta || meta.err) return;
const keys = [...message.accountKeys, ...meta.loadedWritableAddresses, ...meta.loadedReadonlyAddresses].map((k) => bs58.encode(k));
const decimals = new Map(meta.postTokenBalances.map((b) => [b.mint, b.uiTokenAmount?.decimals ?? 0]));
// Flatten top-level and inner instructions in execution order.
const inner = new Map(meta.innerInstructions.map((group) => [group.index, group.instructions]));
const flat = message.instructions.flatMap((outer, i) =>
[outer, ...(inner.get(i) ?? [])].map((ix, j) => ({
programId: keys[ix.programIdIndex],
accounts: [...ix.accounts].map((index) => keys[index] ?? ""),
data: Buffer.from(ix.data),
via: j === 0 ? "direct" : (keys[outer.programIdIndex] ?? "?"),
})),
);
const used = new Set<number>();
flat.forEach((ix, position) => {
if (ix.programId !== METEORA_DLMM) return;
const variant = SWAPS.find((s) => ix.data.subarray(0, 8).equals(s.disc));
if (!variant) return;
const [lbPair = "", , , , , , mintX = "", mintY = ""] = ix.accounts;
if (pair && lbPair !== pair) return;
// The Swap event is a later self-CPI for the same pair.
const index = flat.findIndex((e, k) => k > position && !used.has(k) && e.programId === METEORA_DLMM
&& e.data.length >= 145 && e.data.subarray(0, 16).equals(EVENT_PREFIX) && bs58.encode(e.data.subarray(16, 48)) === lbPair);
if (index < 0) return void console.log(`${slot} ${variant.name} ${lbPair} no Swap event found`);
used.add(index);
const body = flat[index]!.data.subarray(16);
const swapForY = body[88] === 1;
const [inMint, outMint] = swapForY ? [mintX, mintY] : [mintY, mintX];
const startBin = body.readInt32LE(64);
const endBin = body.readInt32LE(68);
console.log([
slot, variant.name, lbPair,
`${ui(body.readBigUInt64LE(72), decimals.get(inMint))} ${inMint}`, "->",
`${ui(body.readBigUInt64LE(80), decimals.get(outMint))} ${outMint}`,
`bins ${startBin === endBin ? startBin : `${startBin}->${endBin}`}`,
`fee ${body.readBigUInt64LE(89)}`, `via ${ix.via}`, bs58.encode(info.signature),
].join(" "));
});
}
const ui = (raw: bigint, decimals: number | undefined) =>
decimals === undefined ? `${raw} raw` : (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;
}
}
Offsets in the code are relative to the event body, which starts after the 16-byte prefix. The companion project adds a fallback for swaps without an event (reserve balance deltas on reserveX and reserveY), reports events whose instruction is not in the IDL as unlisted, and formats amounts with bigint precision.
The parsed swap shape
type DlmmSwap = {
signature: string;
slot: string; // u64 as string
path: string; // "3.2": inner instruction 2 under top-level 3
viaProgram: string | null; // aggregator or bot that called DLMM
kind: "swap" | "swap2" | "swapExactOut" | "swapWithPriceImpact" | "unlisted";
lbPair: string;
user: string;
inputMint?: string;
outputMint?: string;
amountIn?: bigint; // fee-inclusive, raw units
amountOut?: bigint;
requested: bigint; // amount from the instruction arguments
fillSource: "event" | "reserves" | "none";
event?: { startBinId: number; endBinId: number; swapForY: boolean; fee: bigint; protocolFee: bigint; hostFee: bigint };
};
Production pitfalls
Aggregator CPI is the norm. Expect DLMM swaps under Jupiter, Titan and private bot programs. Walk inner instructions, and record the caller.
Program-key matches that do nothing. Many transactions list the DLMM program among their accounts without invoking it, typically bots that prepare routes and bail out. Count decoded swaps, not stream matches.
Several swaps per transaction. Arbitrage often hits the same pair more than once. Pair each instruction with the first unused event for the same lbPair after it, as above, rather than taking the first event in the transaction.
Fee-inclusive input. amountIn includes fee. Subtract it before computing an execution price against bin prices; keep it when computing what the trader paid.
Reserve deltas are a fallback, not a source of truth. When an event is missing, reserve deltas on reserveX/reserveY give net amounts, but they merge every swap on that pair in the transaction.
IDL drift. DLMM adds instructions over time. Swap events whose instruction you did not recognize mean a new variant; log them instead of dropping them.
Lookup tables. Aggregator transactions are almost always versioned with lookup tables. Append the loaded addresses to the key list or every account index past the static keys is wrong.
For production volumes, a nearby dedicated node with Yellowstone gRPC included keeps the stream and the RPC calls for binStep lookups in one place.
FAQ
How do I identify a Meteora DLMM swap without an IDL coder?
Compute sha256("global: and compare its first 8 bytes with the instruction data. swap and swap2 are f8c69e91e17587c8 and 414b3f4ceb5b5b88.
Where do the executed amounts come from?
From the Swap event in the inner instruction that DLMM emits to itself through its event authority. The instruction arguments only hold the requested amount and the slippage limit.
What do startBinId and endBinId tell me?
The active bin before and after the swap. Equal values mean the swap filled in one bin; different values mean it crossed bins and moved the price.
How do I convert a bin ID to a price?
(1 + binStep / 10000) ^ binId, multiplied by 10 ^ (decimalsX - decimalsY) for UI units. Read binStep once from the LbPair account.
Can I follow a single pair?
Set POOL_ADDRESS to the LB pair. The subscription requires both the pair and the program, and the parser skips swaps on other pairs in the same transaction.
References
- Transaction monitoring with Yellowstone gRPC
- Account monitoring with Yellowstone gRPC
- Reconnects and stream load
- @triton-one/yellowstone-grpc on npm
- bs58 on npm
Companion project
The full example lives at solanatracker/examples/10-meteora-dlmm-transaction-parsing. It bundles the DLMM IDL, derives discriminators and account positions from it, falls back to reserve deltas when no event is present, and prints an aligned table with bins, fill source and caller.
cp .env.example .env && npm install && npm start