From 1398d9c1dc55b38d9542e29c4eb87fb03b66a852 Mon Sep 17 00:00:00 2001 From: 0xfnzero <0xfnzero@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:53:19 +0800 Subject: [PATCH] fix: parse current DEX event layouts --- scripts/program-log-discriminators.json | 10 ++ src/core/account_fill_raydium.ts | 9 +- src/current_mainnet_transactions.test.ts | 144 +++++++++++++++++++ src/grpc/log_instr_dedup.test.ts | 41 ++++++ src/grpc/log_instr_dedup.ts | 45 ++++-- src/instr/inner.ts | 54 ++++--- src/instr/meteora_dlmm_ix.ts | 170 ++++++++++++++++++----- src/instr/meteora_pools_dlmm_ix.test.ts | 61 ++++++-- src/instr/orca_whirlpool_ix.test.ts | 6 +- src/instr/orca_whirlpool_ix.ts | 3 +- src/instr/raydium_amm_v4_ix.ts | 95 ++++++++----- src/instr/raydium_clmm_ix.test.ts | 2 +- src/instr/raydium_cpmm_ix.ts | 4 +- src/logs/discriminator_lut.ts | 8 +- src/logs/meteora_dlmm.ts | 141 +++++++++++++++++-- src/logs/optimized_matcher.ts | 38 +++-- src/logs/program_log_discriminators.ts | 2 + src/logs/raydium_amm.ts | 33 +++++ src/logs/raydium_cpmm.ts | 43 ++++++ src/rpc_transaction.test.ts | 128 +++++++++++++++++ src/rpc_transaction.ts | 162 +++++++++++++++++---- 21 files changed, 1025 insertions(+), 174 deletions(-) create mode 100644 src/current_mainnet_transactions.test.ts diff --git a/scripts/program-log-discriminators.json b/scripts/program-log-discriminators.json index 242709c..86fff1d 100644 --- a/scripts/program-log-discriminators.json +++ b/scripts/program-log-discriminators.json @@ -393,6 +393,16 @@ 51, 222 ], + "RAYDIUM_CPMM_SWAP_EVENT": [ + 64, + 198, + 205, + 232, + 38, + 8, + 113, + 226 + ], "RAYDIUM_CPMM_SWAP_BASE_OUT": [ 55, 217, diff --git a/src/core/account_fill_raydium.ts b/src/core/account_fill_raydium.ts index 019a728..e207b9a 100644 --- a/src/core/account_fill_raydium.ts +++ b/src/core/account_fill_raydium.ts @@ -81,9 +81,12 @@ export function fillRaydiumClmmDecreaseLiquidityAccounts( } export function fillRaydiumCpmmSwapAccounts( - _e: RaydiumCpmmSwapEvent, - _get: (i: number) => string -): void {} + e: RaydiumCpmmSwapEvent, + get: (i: number) => string +): void { + const zero = Z(); + if (!e.pool_id || e.pool_id === zero) e.pool_id = get(3); +} export function fillRaydiumCpmmDepositAccounts( e: RaydiumCpmmDepositEvent, diff --git a/src/current_mainnet_transactions.test.ts b/src/current_mainnet_transactions.test.ts new file mode 100644 index 0000000..2e3bab6 --- /dev/null +++ b/src/current_mainnet_transactions.test.ts @@ -0,0 +1,144 @@ +import { Connection } from "@solana/web3.js"; +import { describe, expect, it } from "vitest"; +import type { DexEvent } from "./core/dex_event.js"; +import { parseTransactionFromRpc } from "./rpc_parser.js"; + +const RPC_URL = process.env.SOLANA_RPC_URL || "https://api.mainnet-beta.solana.com"; +const mainnet = process.env.RUN_MAINNET_TESTS === "1" ? describe.sequential : describe.skip; + +function eventsOf(events: DexEvent[], name: string): Record[] { + return events.flatMap((event) => { + const payload = (event as unknown as Record>)[name]; + return payload ? [payload] : []; + }); +} + +async function parse(signature: string): Promise { + const result = await parseTransactionFromRpc( + new Connection(RPC_URL, "confirmed"), + signature + ); + if (!result.ok) throw new Error(`${signature}: ${result.error.kind}: ${result.error.message}`); + return result.events; +} + +function expectFixtureMetadata(event: Record, signature: string, slot: number): void { + expect(event.metadata.signature).toBe(signature); + expect(Number(event.metadata.slot)).toBe(slot); +} + +// Captured from current mainnet transactions on 2026-08-13. Run with: +// RUN_MAINNET_TESTS=1 SOLANA_RPC_URL= npm test +mainnet("current mainnet transaction fixtures", () => { + it("parses the current Meteora DLMM event-CPI swap layout", async () => { + const signature = + "eEWaGsbRPoiD36Xf3epzSMmdtXX36va76b13YfsDV3ncsxQHBTjC68zZ8mbzFXTNWy3n3qKUAHjgHBconX4Gu1i"; + const events = await parse(signature); + const swaps = eventsOf(events, "MeteoraDlmmSwap"); + expect(swaps).toHaveLength(1); + expectFixtureMetadata(swaps[0], signature, 438873646); + expect(swaps[0]).toMatchObject({ + amount_in: 2738183783n, + amount_out: 81555062n, + fee: 18486656n, + protocol_fee: 2054072n, + }); + + const orcaSwaps = eventsOf(events, "OrcaWhirlpoolSwap"); + expect(orcaSwaps).toHaveLength(1); + expect(orcaSwaps[0].whirlpool).toBe("coj59LYbLc6DhMwnxxfPc9mUiknjFSsW4XcuYw4DMPk"); + expect(orcaSwaps[0].input_amount).toBe(2397194654n); + expect(orcaSwaps[0].output_amount).toBe(942951733n); + }, 30_000); + + it("preserves all current Meteora DLMM add-liquidity occurrences", async () => { + const signature = + "h3sGiriW4jCGgbnNF8DaEKnsWhjtcH5ZM1dkyZFidgD2fx9aDNatray38yRmkxaWez3g5qFNpyXhE8ho716vjgp"; + const adds = eventsOf(await parse(signature), "MeteoraDlmmAddLiquidity"); + expect(adds).toHaveLength(3); + expectFixtureMetadata(adds[0], signature, 438873652); + expect(adds.map((event) => event.amounts)).toEqual([ + [0n, 389400592n], + [4957546677n, 65633812n], + [6984260460n, 0n], + ]); + }, 30_000); + + it("parses a current PumpFun buy", async () => { + const signature = + "QUYUtVPVkkjV2GGFTC4MfxtauNpRvWDViGCGxTckxPWbPZvEY92d1sZD9Lq5iK31sy3Drwy28gHmV89iRt9hz9R"; + const buys = eventsOf(await parse(signature), "PumpFunBuy"); + expect(buys).toHaveLength(1); + expectFixtureMetadata(buys[0], signature, 438880952); + expect(buys[0]).toMatchObject({ + sol_amount: 977777777n, + token_amount: 30765521374696n, + ix_name: "buy", + }); + }, 30_000); + + it("parses a current PumpSwap buy", async () => { + const signature = + "2qgqjVi7XtBeSudSkZhbQrsdFNeMUB4jApLdcdihAwcWWb5SxQvQKjrfr2ZQ12TrR4BEwvaY7PiHLqE3uZD24iw7"; + const buys = eventsOf(await parse(signature), "PumpSwapBuy"); + expect(buys).toHaveLength(1); + expectFixtureMetadata(buys[0], signature, 438881023); + expect(buys[0]).toMatchObject({ + base_amount_out: 7317003080n, + quote_amount_in: 10000000n, + ix_name: "buy_exact_quote_in", + }); + }, 30_000); + + it("parses a current Raydium CLMM swap", async () => { + const signature = + "2TyjCWrh3zqNmDg7NgdGAFqGaCbEkUHE8zrjzsRyqRVTXYZNKFFJgFEDce5mD4se3h2u6GyNJLXbeAW1ad8dsApq"; + const swaps = eventsOf(await parse(signature), "RaydiumClmmSwap"); + expect(swaps).toHaveLength(1); + expectFixtureMetadata(swaps[0], signature, 438880315); + expect(swaps[0]).toMatchObject({ amount_0: 156679n, amount_1: 11888n }); + }, 30_000); + + it("preserves all current Raydium CPMM swap occurrences", async () => { + const signature = + "4v27ccyrAgpCdCHLvjvn8smFn4Fb4HGcRVTSt952eNcF5jg5niA5bKRLPoGrzxXZdZULEZujgA5TXdESNbwmFYE8"; + const swaps = eventsOf(await parse(signature), "RaydiumCpmmSwap"); + expect(swaps).toHaveLength(3); + expectFixtureMetadata(swaps[0], signature, 438881024); + expect(swaps.map(({ pool_id, input_amount, output_amount }) => [ + pool_id, + input_amount, + output_amount, + ])).toEqual([ + ["2VhaFEYL1exY86u8tTisfRjNwtCkX8bNbupHvSKzJuQJ", 851111n, 3788666n], + ["3WYho5XjXfAzGsXdwdkFHw84rH6kCGCQnSYg6wHqypQo", 636739n, 1163813842n], + ["DEHrWvTA1npSrcVt7xnWoyxdEgtpD4ZstmEeB2QLgFJj", 1163813842n, 2843080n], + ]); + }, 30_000); + + it("parses the official Raydium AMM V4 ray_log layout", async () => { + const signature = + "2iHYs4AHC5nutcbBxpA5aptBYTGaDUYBgamohfetDnAPiPBW5NkguxgnjVF5886Jy8MZ19UXdeZyPKq9C5wqAki4"; + const swaps = eventsOf(await parse(signature), "RaydiumAmmV4Swap"); + expect(swaps).toHaveLength(1); + expectFixtureMetadata(swaps[0], signature, 438881026); + expect(swaps[0]).toMatchObject({ + amm: "FuemMjepntbzthvSEVmDGnfq7YWr8UebZrAXJrP46VtF", + amount_in: 28804156949609n, + amount_out: 428715251n, + }); + }, 30_000); + + it("parses a current Raydium LaunchLab trade", async () => { + const signature = + "4pSXdZEdL3oFCcbccroG7GkV4oEVtbygS2pBVP28chfNETEN8yqE3q6gMws4F2ZsbfE8rDGEbgBqTnv5xahH5RFT"; + const trades = eventsOf(await parse(signature), "RaydiumLaunchlabTrade"); + expect(trades).toHaveLength(1); + expectFixtureMetadata(trades[0], signature, 438880206); + expect(trades[0]).toMatchObject({ + amount_in: 511580573n, + amount_out: 5169841048834n, + trade_direction: "Buy", + }); + }, 30_000); +}); diff --git a/src/grpc/log_instr_dedup.test.ts b/src/grpc/log_instr_dedup.test.ts index b7870ed..9f9cde2 100644 --- a/src/grpc/log_instr_dedup.test.ts +++ b/src/grpc/log_instr_dedup.test.ts @@ -4,6 +4,47 @@ import { defaultPubkey } from "../core/dex_event.js"; import { dedupeLogInstructionEvents } from "./log_instr_dedup.js"; describe("dedupeLogInstructionEvents", () => { + function clmmSwap(zeroForOne: boolean, amount0: bigint): DexEvent { + return { + RaydiumClmmSwap: { + metadata: {}, + pool_state: "ClmmPool111111111111111111111111111111111", + sender: defaultPubkey(), + token_account_0: defaultPubkey(), + token_account_1: defaultPubkey(), + amount_0: amount0, + amount_1: 0n, + zero_for_one: zeroForOne, + sqrt_price_x64: 0n, + liquidity: 0n, + transfer_fee_0: 0n, + transfer_fee_1: 0n, + tick: 0, + }, + } as DexEvent; + } + + it("dedupes CLMM instruction placeholders even when their direction is not authoritative", () => { + const out = dedupeLogInstructionEvents( + [clmmSwap(false, 123n)], + [clmmSwap(true, 0n)] + ); + + expect(out).toHaveLength(1); + const swap = (out[0] as any).RaydiumClmmSwap; + expect(swap.amount_0).toBe(123n); + expect(swap.zero_for_one).toBe(false); + }); + + it("retains multiple CLMM swaps for the same pool", () => { + const out = dedupeLogInstructionEvents( + [clmmSwap(false, 1n), clmmSwap(true, 2n)], + [clmmSwap(true, 0n), clmmSwap(false, 0n)] + ); + + expect(out).toHaveLength(2); + }); + it("keeps log trade values and fills instruction account fields", () => { const logEvent = { PumpFunTrade: { diff --git a/src/grpc/log_instr_dedup.ts b/src/grpc/log_instr_dedup.ts index ac70bc1..24b7a95 100644 --- a/src/grpc/log_instr_dedup.ts +++ b/src/grpc/log_instr_dedup.ts @@ -78,7 +78,10 @@ function nextOccurrence(base: string, counts: Map): number { return current; } -function dedupeKey(ev: DexEvent, pumpfunLaneCounts: Map): string | null { +function dedupeKey( + ev: DexEvent, + occurrenceCounts: Map +): string | null { const name = eventName(ev); const data = payload(ev); if (!data) return null; @@ -86,7 +89,7 @@ function dedupeKey(ev: DexEvent, pumpfunLaneCounts: Map): string if (PUMPFUN_TRADE_NAMES.has(name)) { const lane = ixLane(data.ix_name); const base = `${data.mint}|${data.user}|${Boolean(data.is_buy)}|${lane}`; - const occurrence = nextOccurrence(base, pumpfunLaneCounts); + const occurrence = nextOccurrence(`PumpFun|${base}`, occurrenceCounts); return `PumpFunTrade|${base}|${occurrence}`; } @@ -115,12 +118,28 @@ function dedupeKey(ev: DexEvent, pumpfunLaneCounts: Map): string return `PumpSwapLiquidityAdded|${data.pool}|${data.user}`; case "PumpSwapLiquidityRemoved": return `PumpSwapLiquidityRemoved|${data.pool}|${data.user}`; - case "RaydiumClmmSwap": - return `RaydiumClmmSwap|${data.pool_state}|${Boolean(data.zero_for_one)}`; - case "RaydiumAmmV4Swap": - return `RaydiumAmmV4Swap|${data.amm}`; - case "MeteoraDlmmSwap": - return `MeteoraDlmmSwap|${data.pool}|${data.from}|${Boolean(data.swap_for_y)}`; + case "RaydiumClmmSwap": { + const base = String(data.pool_state); + return `RaydiumClmmSwap|${base}|${nextOccurrence(`RaydiumClmm|${base}`, occurrenceCounts)}`; + } + case "RaydiumCpmmSwap": { + const base = String(data.pool_id); + return `RaydiumCpmmSwap|${base}|${nextOccurrence(`RaydiumCpmm|${base}`, occurrenceCounts)}`; + } + case "RaydiumAmmV4Swap": { + const base = data.max_amount_in !== 0n && data.max_amount_in !== 0 + ? `out|${data.amount_out}` + : `in|${data.amount_in}`; + return `RaydiumAmmV4Swap|${base}|${nextOccurrence(`RaydiumAmmV4|${base}`, occurrenceCounts)}`; + } + case "OrcaWhirlpoolSwap": { + const base = String(data.whirlpool); + return `OrcaWhirlpoolSwap|${base}|${nextOccurrence(`OrcaWhirlpool|${base}`, occurrenceCounts)}`; + } + case "MeteoraDlmmSwap": { + const base = `${data.pool}|${data.from}|${Boolean(data.swap_for_y)}`; + return `MeteoraDlmmSwap|${base}|${nextOccurrence(`MeteoraDlmm|${base}`, occurrenceCounts)}`; + } default: return null; } @@ -306,6 +325,8 @@ function mergeRaydiumAmmV4Swap(log: EventPayload, ix: EventPayload): void { "serum_vault_signer", "user_source_token_account", "user_destination_token_account", + "user_source_owner", + "amm", ]) { fillString(log, key, ix); } @@ -396,17 +417,17 @@ export function dedupeLogInstructionEvents( ): DexEvent[] { const out: DexEvent[] = []; const indexByKey = new Map(); - const logPumpfunLaneCounts = new Map(); - const ixPumpfunLaneCounts = new Map(); + const logOccurrenceCounts = new Map(); + const ixOccurrenceCounts = new Map(); for (const ev of logEvents) { - const key = dedupeKey(ev, logPumpfunLaneCounts); + const key = dedupeKey(ev, logOccurrenceCounts); if (key) indexByKey.set(key, out.length); out.push(ev); } for (const ev of instructionEvents) { - const key = dedupeKey(ev, ixPumpfunLaneCounts); + const key = dedupeKey(ev, ixOccurrenceCounts); if (!key) { out.push(ev); continue; diff --git a/src/instr/inner.ts b/src/instr/inner.ts index a6506b6..709554a 100644 --- a/src/instr/inner.ts +++ b/src/instr/inner.ts @@ -94,7 +94,7 @@ import { parseSetPoolFeesFromData as parseMeteoraPoolsSetPoolFees, parseSwapFromData as parseMeteoraPoolsSwap, } from "../logs/meteora_amm.js"; -import { parseDlmmFromDecoded } from "../logs/meteora_dlmm.js"; +import { parseDlmmEventFromData } from "../logs/meteora_dlmm.js"; import { parseRaydiumLaunchlabPoolCreateFromData, parseRaydiumLaunchlabTradeFromData, @@ -135,7 +135,7 @@ function filterDexEvent(ev: DexEvent | null, filter?: EventTypeFilter): DexEvent : null; } -function pumpFeesEventDisc(disc: Uint8Array): bigint | null { +function eventCpiDiscriminator(disc: Uint8Array): bigint | null { if (bytesEq(disc, 0, EVENT_CPI_PREFIX)) return readU64LE(disc, 8); if (bytesEq(disc, 8, EVENT_CPI_SUFFIX)) return readU64LE(disc, 0); return null; @@ -202,14 +202,16 @@ const LOG = { METEORA_DAMM_INITIALIZE_POOL: [228, 50, 246, 85, 203, 66, 134, 37], METEORA_DAMM_CREATE_POSITION: [156, 15, 119, 198, 29, 181, 221, 55], METEORA_DAMM_CLOSE_POSITION: [20, 145, 144, 68, 143, 142, 214, 178], - METEORA_DLMM_SWAP: [143, 190, 90, 218, 196, 30, 51, 222], - METEORA_DLMM_ADD_LIQUIDITY: [181, 157, 89, 67, 143, 182, 52, 72], - METEORA_DLMM_REMOVE_LIQUIDITY: [80, 85, 209, 72, 24, 206, 35, 178], - METEORA_DLMM_INITIALIZE_POOL: [95, 180, 10, 172, 84, 174, 232, 40], + METEORA_DLMM_SWAP: [81, 108, 227, 190, 205, 208, 10, 196], + METEORA_DLMM_SWAP2: [46, 116, 82, 215, 148, 27, 84, 77], + METEORA_DLMM_ADD_LIQUIDITY: [31, 94, 125, 90, 227, 52, 61, 186], + METEORA_DLMM_REMOVE_LIQUIDITY: [116, 244, 97, 232, 103, 31, 152, 58], + METEORA_DLMM_INITIALIZE_POOL: [185, 74, 252, 125, 27, 215, 188, 111], METEORA_DLMM_INITIALIZE_BIN_ARRAY: [11, 18, 155, 194, 33, 115, 238, 119], - METEORA_DLMM_CREATE_POSITION: [123, 233, 11, 43, 146, 180, 97, 119], - METEORA_DLMM_CLOSE_POSITION: [94, 168, 102, 45, 59, 122, 137, 54], - METEORA_DLMM_CLAIM_FEE: [152, 70, 208, 111, 104, 91, 44, 1], + METEORA_DLMM_CREATE_POSITION: [144, 142, 252, 84, 157, 53, 37, 121], + METEORA_DLMM_CLOSE_POSITION: [255, 196, 16, 107, 28, 202, 53, 128], + METEORA_DLMM_CLAIM_FEE: [75, 122, 154, 48, 140, 74, 123, 163], + METEORA_DLMM_CLAIM_FEE2: [232, 171, 242, 97, 58, 77, 35, 45], RAYDIUM_LAUNCHLAB_POOL_CREATE: [151, 215, 226, 9, 118, 161, 115, 174], RAYDIUM_LAUNCHLAB_TRADE: [189, 219, 127, 211, 78, 230, 97, 238], } as const; @@ -287,6 +289,28 @@ const IX = { [80, 85, 209, 72, 24, 206, 177, 108], [95, 180, 10, 172, 84, 174, 232, 40], ], + METEORA_DLMM: [ + [181, 157, 89, 67, 143, 182, 52, 72], + [228, 162, 78, 28, 70, 219, 116, 115], + [169, 32, 79, 137, 136, 232, 70, 137], + [112, 191, 101, 171, 28, 144, 127, 187], + [123, 134, 81, 0, 49, 68, 98, 98], + [174, 90, 35, 115, 186, 40, 147, 226], + [35, 86, 19, 185, 78, 212, 75, 211], + [45, 154, 237, 210, 221, 15, 166, 92], + [73, 59, 36, 120, 237, 83, 108, 198], + [219, 192, 234, 71, 190, 191, 102, 80], + [143, 19, 242, 145, 213, 15, 104, 115], + [46, 82, 125, 146, 85, 141, 228, 153], + [80, 85, 209, 72, 24, 206, 177, 108], + [230, 215, 82, 127, 241, 101, 227, 146], + [248, 198, 158, 145, 225, 117, 135, 200], + [65, 75, 63, 76, 235, 91, 91, 136], + [250, 73, 101, 33, 38, 207, 75, 184], + [43, 215, 247, 132, 137, 60, 243, 81], + [56, 173, 230, 208, 173, 228, 156, 205], + [74, 98, 192, 214, 177, 51, 75, 51], + ], } as const; function firstByteIn(data: Uint8Array, allowed: readonly number[]): boolean { @@ -300,7 +324,7 @@ function headIn(data: Uint8Array, discs: readonly (readonly number[])[]): boolea function normalInstructionDataMayParse(programId: string, data: Uint8Array): boolean { if (data.length === 0) return false; if (programId === RAYDIUM_AMM_V4_PROGRAM_ID) return firstByteIn(data, [1, 3, 4, 7, 9, 11]); - if (programId === METEORA_DLMM_PROGRAM_ID) return firstByteIn(data, [0, 1, 2, 7, 8, 11, 13, 14]); + if (programId === METEORA_DLMM_PROGRAM_ID) return headIn(data, IX.METEORA_DLMM); if (programId === METEORA_DAMM_V2_PROGRAM_ID) { return discEq(data, LOG.METEORA_DAMM_INITIALIZE_POOL); } @@ -342,7 +366,7 @@ export function parseInnerCompiledInstructionIfSupported( } function parsePumpFeesInner(disc: Uint8Array, data: Uint8Array, metadata: ReturnType): DexEvent | null { - const eventDisc = pumpFeesEventDisc(disc); + const eventDisc = eventCpiDiscriminator(disc); if (eventDisc === null) return null; if (eventDisc === disc8(LOG.PUMP_FEES_CREATE_FEE_SHARING_CONFIG)) { return parseCreateFeeSharingConfigFromData(data, metadata); @@ -483,11 +507,9 @@ export function parseInnerInstructionUnified( ); } else if (programId === METEORA_DLMM_PROGRAM_ID) { if (filter && !eventTypeFilterIncludesMeteoraDlmm(filter)) return null; - if (!discTailEq(disc, EVENT_CPI_SUFFIX)) return null; - const decoded = new Uint8Array(8 + data.length); - decoded.set(disc.subarray(0, 8), 0); - decoded.set(data, 8); - ev = parseDlmmFromDecoded(decoded, metadata); + const eventDisc = eventCpiDiscriminator(disc); + if (eventDisc === null) return null; + ev = parseDlmmEventFromData(eventDisc, data, metadata); } else if (programId === RAYDIUM_LAUNCHLAB_PROGRAM_ID) { if (filter && !eventTypeFilterIncludesRaydiumLaunchlab(filter)) return null; if (!discTailEq(disc, EVENT_CPI_SUFFIX)) return null; diff --git a/src/instr/meteora_dlmm_ix.ts b/src/instr/meteora_dlmm_ix.ts index 2a76e61..d94b97d 100644 --- a/src/instr/meteora_dlmm_ix.ts +++ b/src/instr/meteora_dlmm_ix.ts @@ -1,10 +1,37 @@ import type { DexEvent } from "../core/dex_event.js"; import { defaultPubkey } from "../core/dex_event.js"; -import { readI32LE, readU16LE, readU32LE, readU64LE } from "../util/binary.js"; +import { readI32LE, readI64LE, readU16LE, readU64LE } from "../util/binary.js"; import { getAccount, ixMeta } from "./utils.js"; const Z = defaultPubkey(); +function discEq(data: Uint8Array, bytes: readonly number[]): boolean { + return data.length >= 8 && bytes.every((b, i) => data[i] === b); +} + +const IX = { + ADD_LIQUIDITY: [181, 157, 89, 67, 143, 182, 52, 72], + ADD_LIQUIDITY2: [228, 162, 78, 28, 70, 219, 116, 115], + CLAIM_FEE: [169, 32, 79, 137, 136, 232, 70, 137], + CLAIM_FEE2: [112, 191, 101, 171, 28, 144, 127, 187], + CLOSE_POSITION: [123, 134, 81, 0, 49, 68, 98, 98], + CLOSE_POSITION2: [174, 90, 35, 115, 186, 40, 147, 226], + INITIALIZE_BIN_ARRAY: [35, 86, 19, 185, 78, 212, 75, 211], + INITIALIZE_LB_PAIR: [45, 154, 237, 210, 221, 15, 166, 92], + INITIALIZE_LB_PAIR2: [73, 59, 36, 120, 237, 83, 108, 198], + INITIALIZE_POSITION: [219, 192, 234, 71, 190, 191, 102, 80], + INITIALIZE_POSITION2: [143, 19, 242, 145, 213, 15, 104, 115], + INITIALIZE_POSITION_PDA: [46, 82, 125, 146, 85, 141, 228, 153], + REMOVE_LIQUIDITY: [80, 85, 209, 72, 24, 206, 177, 108], + REMOVE_LIQUIDITY2: [230, 215, 82, 127, 241, 101, 227, 146], + SWAP: [248, 198, 158, 145, 225, 117, 135, 200], + SWAP2: [65, 75, 63, 76, 235, 91, 91, 136], + SWAP_EXACT_OUT: [250, 73, 101, 33, 38, 207, 75, 184], + SWAP_EXACT_OUT2: [43, 215, 247, 132, 137, 60, 243, 81], + SWAP_WITH_PRICE_IMPACT: [56, 173, 230, 208, 173, 228, 156, 205], + SWAP_WITH_PRICE_IMPACT2: [74, 98, 192, 214, 177, 51, 75, 51], +} as const; + export function parseMeteoraDlmmInstruction( instructionData: Uint8Array, accounts: string[], @@ -14,96 +41,128 @@ export function parseMeteoraDlmmInstruction( blockTimeUs: number | undefined, grpcRecvUs: number ): DexEvent | null { - if (instructionData.length === 0) return null; - const pool = getAccount(accounts, 0); - if (!pool) return null; + if (instructionData.length < 8) return null; const metadata = ixMeta(signature, slot, txIndex, blockTimeUs, grpcRecvUs); - const kind = instructionData[0]!; + const data = instructionData.subarray(8); - if (kind === 0) { - if (instructionData.length < 1 + 4 + 2) return null; - const active_bin_id = readI32LE(instructionData, 1); - const bin_step = readU16LE(instructionData, 5); + if (discEq(instructionData, IX.INITIALIZE_LB_PAIR)) { + const pool = getAccount(accounts, 0); + if (!pool || data.length < 6) return null; + const active_bin_id = readI32LE(data, 0); + const bin_step = readU16LE(data, 4); if (active_bin_id === null || bin_step === null) return null; return { MeteoraDlmmInitializePool: { metadata, pool, - creator: getAccount(accounts, 1) ?? Z, + creator: getAccount(accounts, 8) ?? Z, active_bin_id, bin_step, }, }; } - if (kind === 1) { - if (instructionData.length < 1 + 8) return null; + if (discEq(instructionData, IX.INITIALIZE_LB_PAIR2)) { + const pool = getAccount(accounts, 0); + if (!pool || data.length < 4) return null; + return { + MeteoraDlmmInitializePool: { + metadata, + pool, + creator: getAccount(accounts, 8) ?? Z, + active_bin_id: readI32LE(data, 0) ?? 0, + bin_step: 0, + }, + }; + } + + if (discEq(instructionData, IX.INITIALIZE_BIN_ARRAY)) { + const pool = getAccount(accounts, 0); + if (!pool || data.length < 8) return null; return { MeteoraDlmmInitializeBinArray: { metadata, pool, bin_array: getAccount(accounts, 1) ?? Z, - index: readU64LE(instructionData, 1) ?? 0n, + index: readI64LE(data, 0) ?? 0n, }, }; } - if (kind === 2) { - if (instructionData.length < 1 + 32) return null; + if (discEq(instructionData, IX.ADD_LIQUIDITY) || discEq(instructionData, IX.ADD_LIQUIDITY2)) { + const pool = getAccount(accounts, 1); + if (!pool) return null; + const senderIndex = discEq(instructionData, IX.ADD_LIQUIDITY2) ? 9 : 11; + const from = getAccount(accounts, senderIndex); + if (!from) return null; return { MeteoraDlmmAddLiquidity: { metadata, pool, - from: getAccount(accounts, 1) ?? Z, - position: getAccount(accounts, 2) ?? Z, + from, + position: getAccount(accounts, 0) ?? Z, amounts: [0n, 0n], active_bin_id: 0, }, }; } - if (kind === 7) { - if (instructionData.length < 1 + 32) return null; + if (discEq(instructionData, IX.REMOVE_LIQUIDITY) || discEq(instructionData, IX.REMOVE_LIQUIDITY2)) { + const pool = getAccount(accounts, 1); + if (!pool) return null; + const senderIndex = discEq(instructionData, IX.REMOVE_LIQUIDITY2) ? 9 : 11; + const from = getAccount(accounts, senderIndex); + if (!from) return null; return { MeteoraDlmmRemoveLiquidity: { metadata, pool, - from: getAccount(accounts, 1) ?? Z, - position: getAccount(accounts, 2) ?? Z, + from, + position: getAccount(accounts, 0) ?? Z, amounts: [0n, 0n], active_bin_id: 0, }, }; } - if (kind === 8) { - if (instructionData.length < 1 + 4 + 4) return null; - const lower_bin_id = readI32LE(instructionData, 1); - const width = readU32LE(instructionData, 5); - if (lower_bin_id === null || width === null) return null; + if ( + discEq(instructionData, IX.INITIALIZE_POSITION) || + discEq(instructionData, IX.INITIALIZE_POSITION2) || + discEq(instructionData, IX.INITIALIZE_POSITION_PDA) + ) { + if (data.length < 8) return null; + const lower_bin_id = readI32LE(data, 0); + const width = readI32LE(data, 4); + if (lower_bin_id === null || width === null || width < 0) return null; + const pda = discEq(instructionData, IX.INITIALIZE_POSITION_PDA); + const position = getAccount(accounts, pda ? 2 : 1); + const pool = getAccount(accounts, pda ? 3 : 2); + const owner = getAccount(accounts, pda ? 4 : 3); + if (!position || !pool || !owner) return null; return { MeteoraDlmmCreatePosition: { metadata, pool, - position: getAccount(accounts, 1) ?? Z, - owner: getAccount(accounts, 2) ?? Z, + position, + owner, lower_bin_id, width, }, }; } - if (kind === 11) { - if (instructionData.length < 1 + 8 + 8) return null; + if (discEq(instructionData, IX.SWAP) || discEq(instructionData, IX.SWAP2)) { + const pool = getAccount(accounts, 0); + if (!pool || data.length < 16) return null; return { MeteoraDlmmSwap: { metadata, pool, - from: getAccount(accounts, 1) ?? Z, + from: getAccount(accounts, 10) ?? Z, start_bin_id: 0, end_bin_id: 0, - amount_in: readU64LE(instructionData, 1) ?? 0n, + amount_in: readU64LE(data, 0) ?? 0n, amount_out: 0n, swap_for_y: false, fee: 0n, @@ -114,26 +173,61 @@ export function parseMeteoraDlmmInstruction( }; } - if (kind === 13) { + if ( + discEq(instructionData, IX.SWAP_EXACT_OUT) || + discEq(instructionData, IX.SWAP_EXACT_OUT2) || + discEq(instructionData, IX.SWAP_WITH_PRICE_IMPACT) || + discEq(instructionData, IX.SWAP_WITH_PRICE_IMPACT2) + ) { + const pool = getAccount(accounts, 0); + if (!pool || data.length < 8) return null; + return { + MeteoraDlmmSwap: { + metadata, + pool, + from: getAccount(accounts, 10) ?? Z, + start_bin_id: 0, + end_bin_id: 0, + amount_in: readU64LE(data, 0) ?? 0n, + amount_out: discEq(instructionData, IX.SWAP_EXACT_OUT) || discEq(instructionData, IX.SWAP_EXACT_OUT2) + ? readU64LE(data, 8) ?? 0n + : 0n, + swap_for_y: false, + fee: 0n, + protocol_fee: 0n, + fee_bps: 0n, + host_fee: 0n, + }, + }; + } + + if (discEq(instructionData, IX.CLAIM_FEE) || discEq(instructionData, IX.CLAIM_FEE2)) { + const pool = getAccount(accounts, 0); + const owner = getAccount(accounts, discEq(instructionData, IX.CLAIM_FEE2) ? 2 : 4); + if (!pool || !owner) return null; return { MeteoraDlmmClaimFee: { metadata, pool, position: getAccount(accounts, 1) ?? Z, - owner: getAccount(accounts, 2) ?? Z, + owner, fee_x: 0n, fee_y: 0n, }, }; } - if (kind === 14) { + if (discEq(instructionData, IX.CLOSE_POSITION) || discEq(instructionData, IX.CLOSE_POSITION2)) { + const position = getAccount(accounts, 0); + const v2 = discEq(instructionData, IX.CLOSE_POSITION2); + const owner = getAccount(accounts, v2 ? 1 : 4); + if (!position || !owner) return null; return { MeteoraDlmmClosePosition: { metadata, - pool, - position: getAccount(accounts, 1) ?? Z, - owner: getAccount(accounts, 2) ?? Z, + pool: v2 ? Z : getAccount(accounts, 1) ?? Z, + position, + owner, }, }; } diff --git a/src/instr/meteora_pools_dlmm_ix.test.ts b/src/instr/meteora_pools_dlmm_ix.test.ts index 82716c3..c5f9a6c 100644 --- a/src/instr/meteora_pools_dlmm_ix.test.ts +++ b/src/instr/meteora_pools_dlmm_ix.test.ts @@ -8,8 +8,13 @@ import { METEORA_POOLS_PROGRAM_ID, } from "./program_ids.js"; import { parseInstructionUnified } from "./mod.js"; +import { parseInnerInstructionUnified } from "./inner.js"; const METEORA_POOLS_SWAP_DISC = [248, 198, 158, 145, 225, 117, 135, 200] as const; +const METEORA_DLMM_SWAP_DISC = [248, 198, 158, 145, 225, 117, 135, 200] as const; +const METEORA_DLMM_ADD_LIQUIDITY2_DISC = [228, 162, 78, 28, 70, 219, 116, 115] as const; +const METEORA_DLMM_SWAP2_EVENT_DISC = [46, 116, 82, 215, 148, 27, 84, 77] as const; +const EVENT_CPI_PREFIX = [228, 69, 165, 46, 81, 203, 154, 29] as const; function accounts(n: number): string[] { return Array.from({ length: n }, (_, i) => `account_${i}`); @@ -23,15 +28,6 @@ function u64Instruction(disc: readonly number[], ...values: bigint[]): Uint8Arra return data; } -function dlmmSwapInstruction(amountIn: bigint, minOut: bigint): Uint8Array { - const data = new Uint8Array(1 + 8 + 8); - data[0] = 11; - const view = new DataView(data.buffer); - view.setBigUint64(1, amountIn, true); - view.setBigUint64(9, minOut, true); - return data; -} - describe("Meteora Pools and DLMM instruction parity", () => { it("routes Meteora Pools outer swap through parseInstructionUnified", () => { const ev = parseInstructionUnified( @@ -70,8 +66,8 @@ describe("Meteora Pools and DLMM instruction parity", () => { it("routes Meteora DLMM outer swap through parseInstructionUnified", () => { const ev = parseInstructionUnified( - dlmmSwapInstruction(333n, 444n), - accounts(3), + u64Instruction(METEORA_DLMM_SWAP_DISC, 333n, 444n), + accounts(11), "sig", 1, 0, @@ -86,7 +82,48 @@ describe("Meteora Pools and DLMM instruction parity", () => { expect("MeteoraDlmmSwap" in ev!).toBe(true); const data = ev && "MeteoraDlmmSwap" in ev ? ev.MeteoraDlmmSwap : null; expect(data?.pool).toBe("account_0"); - expect(data?.from).toBe("account_1"); + expect(data?.from).toBe("account_10"); expect(data?.amount_in).toBe(333n); }); + + it("uses the add_liquidity2 sender index from the current IDL", () => { + const ev = parseInstructionUnified( + u64Instruction(METEORA_DLMM_ADD_LIQUIDITY2_DISC), + accounts(14), + "sig", + 1, + 0, + undefined, + 10, + undefined, + METEORA_DLMM_PROGRAM_ID + ); + expect(ev && "MeteoraDlmmAddLiquidity" in ev ? ev.MeteoraDlmmAddLiquidity.from : null) + .toBe("account_9"); + }); + + it("parses current Anchor event-CPI DLMM Swap2 without rebuilding the payload", () => { + const ix = new Uint8Array(16 + 147); + ix.set(EVENT_CPI_PREFIX, 0); + ix.set(METEORA_DLMM_SWAP2_EVENT_DISC, 8); + const view = new DataView(ix.buffer); + ix[16 + 72] = 1; + view.setBigUint64(16 + 89, 100n, true); + view.setBigUint64(16 + 105, 90n, true); + + const ev = parseInnerInstructionUnified( + ix, + [], + "sig", + 1, + 0, + undefined, + 10, + eventTypeFilterIncludeOnly(["MeteoraDlmmSwap"]), + METEORA_DLMM_PROGRAM_ID + ); + const swap = ev && "MeteoraDlmmSwap" in ev ? ev.MeteoraDlmmSwap : null; + expect(swap?.amount_in).toBe(100n); + expect(swap?.amount_out).toBe(90n); + }); }); diff --git a/src/instr/orca_whirlpool_ix.test.ts b/src/instr/orca_whirlpool_ix.test.ts index ccf4091..464235d 100644 --- a/src/instr/orca_whirlpool_ix.test.ts +++ b/src/instr/orca_whirlpool_ix.test.ts @@ -71,7 +71,7 @@ describe("Orca Whirlpool instruction parity", () => { ); expect(ev && "OrcaWhirlpoolSwap" in ev).toBe(true); const data = ev && "OrcaWhirlpoolSwap" in ev ? ev.OrcaWhirlpoolSwap : null; - expect(data?.whirlpool).toBe("account_1"); + expect(data?.whirlpool).toBe("account_2"); expect(data?.a_to_b).toBe(false); expect(data?.pre_sqrt_price).toBe(sqrtPriceLimit); expect(data?.post_sqrt_price).toBe(0n); @@ -80,7 +80,7 @@ describe("Orca Whirlpool instruction parity", () => { const swapV2 = parseInstructionUnified( swapInstruction(SWAP_V2_DISC, 333n, 444n, sqrtPriceLimit + 1n, false, true), - accounts(4), + accounts(5), "sig", 1, 0, @@ -91,7 +91,7 @@ describe("Orca Whirlpool instruction parity", () => { ); expect(swapV2 && "OrcaWhirlpoolSwap" in swapV2).toBe(true); const swapV2Data = swapV2 && "OrcaWhirlpoolSwap" in swapV2 ? swapV2.OrcaWhirlpoolSwap : null; - expect(swapV2Data?.whirlpool).toBe("account_1"); + expect(swapV2Data?.whirlpool).toBe("account_4"); expect(swapV2Data?.a_to_b).toBe(true); expect(swapV2Data?.pre_sqrt_price).toBe(sqrtPriceLimit + 1n); expect(swapV2Data?.input_amount).toBe(0n); diff --git a/src/instr/orca_whirlpool_ix.ts b/src/instr/orca_whirlpool_ix.ts index ac4b099..f7a9215 100644 --- a/src/instr/orca_whirlpool_ix.ts +++ b/src/instr/orca_whirlpool_ix.ts @@ -47,10 +47,11 @@ export function parseOrcaWhirlpoolInstruction( if (amount_specified_is_input === null || a_to_b === null) return null; const input_amount = amount_specified_is_input ? amount : 0n; const output_amount = amount_specified_is_input ? other_threshold : amount; + const whirlpoolIndex = discEq(instructionData, DISC.SWAP_V2) ? 4 : 2; return { OrcaWhirlpoolSwap: { metadata: meta, - whirlpool: getAccount(accounts, 1) ?? Z, + whirlpool: getAccount(accounts, whirlpoolIndex) ?? Z, a_to_b, pre_sqrt_price: sqrt_price_limit, post_sqrt_price: 0n, diff --git a/src/instr/raydium_amm_v4_ix.ts b/src/instr/raydium_amm_v4_ix.ts index f268863..2c84a7d 100644 --- a/src/instr/raydium_amm_v4_ix.ts +++ b/src/instr/raydium_amm_v4_ix.ts @@ -35,6 +35,27 @@ const SWAP = { USER_OWNER: 17, } as const; +function swapAccountIndexes(accountCount: number): Record { + if (accountCount !== 17) return SWAP; + return { + ...SWAP, + AMM_TARGET_ORDERS: -1, + POOL_COIN_TOKEN: 4, + POOL_PC_TOKEN: 5, + SERUM_PROGRAM: 6, + SERUM_MARKET: 7, + SERUM_BIDS: 8, + SERUM_ASKS: 9, + SERUM_EVENT_QUEUE: 10, + SERUM_COIN_VAULT: 11, + SERUM_PC_VAULT: 12, + SERUM_VAULT_SIGNER: 13, + USER_SOURCE_TOKEN: 14, + USER_DEST_TOKEN: 15, + USER_OWNER: 16, + }; +} + function swapBaseInFromIx( instructionData: Uint8Array, accounts: string[], @@ -44,6 +65,7 @@ function swapBaseInFromIx( const amount_in = readU64LE(instructionData, 1) ?? 0n; const minimum_amount_out = readU64LE(instructionData, 9) ?? 0n; const g = (i: number) => getAccount(accounts, i) ?? Z; + const indexes = swapAccountIndexes(accounts.length); return { RaydiumAmmV4Swap: { metadata: meta, @@ -51,24 +73,24 @@ function swapBaseInFromIx( minimum_amount_out, max_amount_in: 0n, amount_out: 0n, - token_program: g(SWAP.TOKEN_PROGRAM), - amm: g(SWAP.AMM), - amm_authority: g(SWAP.AMM_AUTHORITY), - amm_open_orders: g(SWAP.AMM_OPEN_ORDERS), - amm_target_orders: g(SWAP.AMM_TARGET_ORDERS), - pool_coin_token_account: g(SWAP.POOL_COIN_TOKEN), - pool_pc_token_account: g(SWAP.POOL_PC_TOKEN), - serum_program: g(SWAP.SERUM_PROGRAM), - serum_market: g(SWAP.SERUM_MARKET), - serum_bids: g(SWAP.SERUM_BIDS), - serum_asks: g(SWAP.SERUM_ASKS), - serum_event_queue: g(SWAP.SERUM_EVENT_QUEUE), - serum_coin_vault_account: g(SWAP.SERUM_COIN_VAULT), - serum_pc_vault_account: g(SWAP.SERUM_PC_VAULT), - serum_vault_signer: g(SWAP.SERUM_VAULT_SIGNER), - user_source_token_account: g(SWAP.USER_SOURCE_TOKEN), - user_destination_token_account: g(SWAP.USER_DEST_TOKEN), - user_source_owner: g(SWAP.USER_OWNER), + token_program: g(indexes.TOKEN_PROGRAM), + amm: g(indexes.AMM), + amm_authority: g(indexes.AMM_AUTHORITY), + amm_open_orders: g(indexes.AMM_OPEN_ORDERS), + amm_target_orders: g(indexes.AMM_TARGET_ORDERS), + pool_coin_token_account: g(indexes.POOL_COIN_TOKEN), + pool_pc_token_account: g(indexes.POOL_PC_TOKEN), + serum_program: g(indexes.SERUM_PROGRAM), + serum_market: g(indexes.SERUM_MARKET), + serum_bids: g(indexes.SERUM_BIDS), + serum_asks: g(indexes.SERUM_ASKS), + serum_event_queue: g(indexes.SERUM_EVENT_QUEUE), + serum_coin_vault_account: g(indexes.SERUM_COIN_VAULT), + serum_pc_vault_account: g(indexes.SERUM_PC_VAULT), + serum_vault_signer: g(indexes.SERUM_VAULT_SIGNER), + user_source_token_account: g(indexes.USER_SOURCE_TOKEN), + user_destination_token_account: g(indexes.USER_DEST_TOKEN), + user_source_owner: g(indexes.USER_OWNER), }, }; } @@ -82,6 +104,7 @@ function swapBaseOutFromIx( const max_amount_in = readU64LE(instructionData, 1) ?? 0n; const amount_out = readU64LE(instructionData, 9) ?? 0n; const g = (i: number) => getAccount(accounts, i) ?? Z; + const indexes = swapAccountIndexes(accounts.length); return { RaydiumAmmV4Swap: { metadata: meta, @@ -89,24 +112,24 @@ function swapBaseOutFromIx( minimum_amount_out: 0n, max_amount_in, amount_out, - token_program: g(SWAP.TOKEN_PROGRAM), - amm: g(SWAP.AMM), - amm_authority: g(SWAP.AMM_AUTHORITY), - amm_open_orders: g(SWAP.AMM_OPEN_ORDERS), - amm_target_orders: g(SWAP.AMM_TARGET_ORDERS), - pool_coin_token_account: g(SWAP.POOL_COIN_TOKEN), - pool_pc_token_account: g(SWAP.POOL_PC_TOKEN), - serum_program: g(SWAP.SERUM_PROGRAM), - serum_market: g(SWAP.SERUM_MARKET), - serum_bids: g(SWAP.SERUM_BIDS), - serum_asks: g(SWAP.SERUM_ASKS), - serum_event_queue: g(SWAP.SERUM_EVENT_QUEUE), - serum_coin_vault_account: g(SWAP.SERUM_COIN_VAULT), - serum_pc_vault_account: g(SWAP.SERUM_PC_VAULT), - serum_vault_signer: g(SWAP.SERUM_VAULT_SIGNER), - user_source_token_account: g(SWAP.USER_SOURCE_TOKEN), - user_destination_token_account: g(SWAP.USER_DEST_TOKEN), - user_source_owner: g(SWAP.USER_OWNER), + token_program: g(indexes.TOKEN_PROGRAM), + amm: g(indexes.AMM), + amm_authority: g(indexes.AMM_AUTHORITY), + amm_open_orders: g(indexes.AMM_OPEN_ORDERS), + amm_target_orders: g(indexes.AMM_TARGET_ORDERS), + pool_coin_token_account: g(indexes.POOL_COIN_TOKEN), + pool_pc_token_account: g(indexes.POOL_PC_TOKEN), + serum_program: g(indexes.SERUM_PROGRAM), + serum_market: g(indexes.SERUM_MARKET), + serum_bids: g(indexes.SERUM_BIDS), + serum_asks: g(indexes.SERUM_ASKS), + serum_event_queue: g(indexes.SERUM_EVENT_QUEUE), + serum_coin_vault_account: g(indexes.SERUM_COIN_VAULT), + serum_pc_vault_account: g(indexes.SERUM_PC_VAULT), + serum_vault_signer: g(indexes.SERUM_VAULT_SIGNER), + user_source_token_account: g(indexes.USER_SOURCE_TOKEN), + user_destination_token_account: g(indexes.USER_DEST_TOKEN), + user_source_owner: g(indexes.USER_OWNER), }, }; } diff --git a/src/instr/raydium_clmm_ix.test.ts b/src/instr/raydium_clmm_ix.test.ts index 6c0ef54..a6bbf77 100644 --- a/src/instr/raydium_clmm_ix.test.ts +++ b/src/instr/raydium_clmm_ix.test.ts @@ -214,7 +214,7 @@ describe("Raydium CPMM instruction parity", () => { ); expect(swap && "RaydiumCpmmSwap" in swap).toBe(true); const swapData = swap && "RaydiumCpmmSwap" in swap ? swap.RaydiumCpmmSwap : null; - expect(swapData?.pool_id).toBe("11111111111111111111111111111111"); + expect(swapData?.pool_id).toBe("account_3"); expect(swapData?.input_amount).toBe(0n); expect(swapData?.output_amount).toBe(0n); expect(swapData?.base_input).toBe(true); diff --git a/src/instr/raydium_cpmm_ix.ts b/src/instr/raydium_cpmm_ix.ts index 47b89da..288efa1 100644 --- a/src/instr/raydium_cpmm_ix.ts +++ b/src/instr/raydium_cpmm_ix.ts @@ -39,7 +39,7 @@ export function parseRaydiumCpmmInstruction( return { RaydiumCpmmSwap: { metadata: meta, - pool_id: Z, + pool_id: getAccount(accounts, 3) ?? Z, input_amount: 0n, output_amount: 0n, input_vault_before: 0n, @@ -56,7 +56,7 @@ export function parseRaydiumCpmmInstruction( return { RaydiumCpmmSwap: { metadata: meta, - pool_id: Z, + pool_id: getAccount(accounts, 3) ?? Z, input_amount: 0n, output_amount: 0n, input_vault_before: 0n, diff --git a/src/logs/discriminator_lut.ts b/src/logs/discriminator_lut.ts index 56d0537..933ff9d 100644 --- a/src/logs/discriminator_lut.ts +++ b/src/logs/discriminator_lut.ts @@ -33,8 +33,12 @@ const NAME_BY_DISC = new Map(); const PROTOCOL_BY_DISC = new Map(); for (const [name, disc] of Object.entries(PROGRAM_LOG_DISC)) { - NAME_BY_DISC.set(disc, name); - PROTOCOL_BY_DISC.set(disc, protocolForProgramLogKey(name)); + // Some Anchor event names intentionally share a discriminator across programs. + // Keep the first canonical unscoped entry; program-aware parsing disambiguates them. + if (!NAME_BY_DISC.has(disc)) { + NAME_BY_DISC.set(disc, name); + PROTOCOL_BY_DISC.set(disc, protocolForProgramLogKey(name)); + } } /** Rust `discriminator_to_name` */ diff --git a/src/logs/meteora_dlmm.ts b/src/logs/meteora_dlmm.ts index e1ea070..67d6bd1 100644 --- a/src/logs/meteora_dlmm.ts +++ b/src/logs/meteora_dlmm.ts @@ -11,6 +11,7 @@ import type { MeteoraDlmmRemoveLiquidityEvent, MeteoraDlmmSwapEvent, } from "../core/dex_event.js"; +import { defaultPubkey } from "../core/dex_event.js"; import { decodeProgramDataLine } from "./program_data.js"; import { readBool, readPubkey, readI32LE, readU16LE, readU32LE, readU64LE, readU128LE } from "../util/binary.js"; @@ -21,14 +22,23 @@ function disc(bytes: readonly number[]): bigint { } const DLMM = { - SWAP: disc([143, 190, 90, 218, 196, 30, 51, 222]), - ADD_LIQ: disc([181, 157, 89, 67, 143, 182, 52, 72]), - REMOVE_LIQ: disc([80, 85, 209, 72, 24, 206, 35, 178]), + SWAP: disc([81, 108, 227, 190, 205, 208, 10, 196]), + SWAP2: disc([46, 116, 82, 215, 148, 27, 84, 77]), + ADD_LIQ: disc([31, 94, 125, 90, 227, 52, 61, 186]), + REMOVE_LIQ: disc([116, 244, 97, 232, 103, 31, 152, 58]), INIT_BIN_ARRAY: disc([11, 18, 155, 194, 33, 115, 238, 119]), - INIT_POOL: disc([95, 180, 10, 172, 84, 174, 232, 40]), - CREATE_POS: disc([123, 233, 11, 43, 146, 180, 97, 119]), - CLOSE_POS: disc([94, 168, 102, 45, 59, 122, 137, 54]), - CLAIM_FEE: disc([152, 70, 208, 111, 104, 91, 44, 1]), + INIT_POOL: disc([185, 74, 252, 125, 27, 215, 188, 111]), + CREATE_POS: disc([144, 142, 252, 84, 157, 53, 37, 121]), + CLOSE_POS: disc([255, 196, 16, 107, 28, 202, 53, 128]), + CLAIM_FEE: disc([75, 122, 154, 48, 140, 74, 123, 163]), + CLAIM_FEE2: disc([232, 171, 242, 97, 58, 77, 35, 45]), + LEGACY_SWAP: disc([143, 190, 90, 218, 196, 30, 51, 222]), + LEGACY_ADD_LIQ: disc([181, 157, 89, 67, 143, 182, 52, 72]), + LEGACY_REMOVE_LIQ: disc([80, 85, 209, 72, 24, 206, 35, 178]), + LEGACY_INIT_POOL: disc([95, 180, 10, 172, 84, 174, 232, 40]), + LEGACY_CREATE_POS: disc([123, 233, 11, 43, 146, 180, 97, 119]), + LEGACY_CLOSE_POS: disc([94, 168, 102, 45, 59, 122, 137, 54]), + LEGACY_CLAIM_FEE: disc([152, 70, 208, 111, 104, 91, 44, 1]), }; function bn64(v: ReturnType): bigint { @@ -40,8 +50,16 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet const dv = new DataView(programData.buffer, programData.byteOffset, 8); const discriminator = dv.getBigUint64(0, true); const data = programData.subarray(8); + return parseDlmmEventFromData(discriminator, data, metadata); +} - if (discriminator === DLMM.SWAP) { +export function parseDlmmEventFromData( + discriminator: bigint, + data: Uint8Array, + metadata: EventMetadata +): DexEvent | null { + if (discriminator === DLMM.SWAP || discriminator === DLMM.LEGACY_SWAP) { + if (data.length < 129) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -81,7 +99,51 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet return { MeteoraDlmmSwap: ev }; } - if (discriminator === DLMM.ADD_LIQ) { + if (discriminator === DLMM.SWAP2) { + if (data.length < 147) return null; + let o = 0; + const pool = readPubkey(data, o)!; + o += 32; + const from = readPubkey(data, o)!; + o += 32; + const start_bin_id = readI32LE(data, o)!; + o += 4; + const end_bin_id = readI32LE(data, o)!; + o += 4; + const swap_for_y = readBool(data, o)!; + o += 1; + const fee_bps = readU128LE(data, o)!; + o += 16; + const amount_in = bn64(readU64LE(data, o)); + o += 8; + o += 8; // amount_left + const amount_out = bn64(readU64LE(data, o)); + o += 8; + const fee = bn64(readU64LE(data, o)); + o += 8; + const protocol_fee = bn64(readU64LE(data, o)); + o += 8; + o += 8; // limit_order_fee + const host_fee = bn64(readU64LE(data, o)); + const ev: MeteoraDlmmSwapEvent = { + metadata, + pool, + from, + start_bin_id, + end_bin_id, + amount_in, + amount_out, + swap_for_y, + fee, + protocol_fee, + fee_bps, + host_fee, + }; + return { MeteoraDlmmSwap: ev }; + } + + if (discriminator === DLMM.ADD_LIQ || discriminator === DLMM.LEGACY_ADD_LIQ) { + if (data.length < 116) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -105,7 +167,8 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet return { MeteoraDlmmAddLiquidity: ev }; } - if (discriminator === DLMM.REMOVE_LIQ) { + if (discriminator === DLMM.REMOVE_LIQ || discriminator === DLMM.LEGACY_REMOVE_LIQ) { + if (data.length < 116) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -130,6 +193,23 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet } if (discriminator === DLMM.INIT_POOL) { + if (data.length < 98) return null; + let o = 0; + const pool = readPubkey(data, o)!; + o += 32; + const bin_step = readU16LE(data, o)!; + const ev: MeteoraDlmmInitializePoolEvent = { + metadata, + pool, + creator: defaultPubkey(), + active_bin_id: 0, + bin_step, + }; + return { MeteoraDlmmInitializePool: ev }; + } + + if (discriminator === DLMM.LEGACY_INIT_POOL) { + if (data.length < 70) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -149,6 +229,7 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet } if (discriminator === DLMM.INIT_BIN_ARRAY) { + if (data.length < 72) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -160,6 +241,26 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet } if (discriminator === DLMM.CREATE_POS) { + if (data.length < 96) return null; + let o = 0; + const pool = readPubkey(data, o)!; + o += 32; + const position = readPubkey(data, o)!; + o += 32; + const owner = readPubkey(data, o)!; + const ev: MeteoraDlmmCreatePositionEvent = { + metadata, + pool, + position, + owner, + lower_bin_id: 0, + width: 0, + }; + return { MeteoraDlmmCreatePosition: ev }; + } + + if (discriminator === DLMM.LEGACY_CREATE_POS) { + if (data.length < 104) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -182,6 +283,22 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet } if (discriminator === DLMM.CLOSE_POS) { + if (data.length < 64) return null; + let o = 0; + const position = readPubkey(data, o)!; + o += 32; + const owner = readPubkey(data, o)!; + const ev: MeteoraDlmmClosePositionEvent = { + metadata, + pool: defaultPubkey(), + position, + owner, + }; + return { MeteoraDlmmClosePosition: ev }; + } + + if (discriminator === DLMM.LEGACY_CLOSE_POS) { + if (data.length < 96) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; @@ -192,7 +309,9 @@ export function parseDlmmFromDecoded(programData: Uint8Array, metadata: EventMet return { MeteoraDlmmClosePosition: ev }; } - if (discriminator === DLMM.CLAIM_FEE) { + if (discriminator === DLMM.CLAIM_FEE || discriminator === DLMM.CLAIM_FEE2 || discriminator === DLMM.LEGACY_CLAIM_FEE) { + const requiredLength = discriminator === DLMM.CLAIM_FEE2 ? 116 : 112; + if (data.length < requiredLength) return null; let o = 0; const pool = readPubkey(data, o)!; o += 32; diff --git a/src/logs/optimized_matcher.ts b/src/logs/optimized_matcher.ts index 861ad52..3f39f94 100644 --- a/src/logs/optimized_matcher.ts +++ b/src/logs/optimized_matcher.ts @@ -46,6 +46,7 @@ import { import { parseCreatePoolFromData as parseCpmmCreatePool, parseDepositFromData as parseCpmmDeposit, + parseSwapEventFromData as parseCpmmSwapEvent, parseSwapBaseInFromData as parseCpmmSwapIn, parseSwapBaseOutFromData as parseCpmmSwapOut, parseWithdrawFromData as parseCpmmWithdraw, @@ -56,6 +57,7 @@ import { parseInitialize2FromData, parseSwapBaseInFromData as parseAmmSwapIn, parseSwapBaseOutFromData as parseAmmSwapOut, + parseRayLogSwap as parseAmmRayLogSwap, parseWithdrawFromData as parseAmmWithdraw, parseWithdrawPnlFromData as parseAmmWithdrawPnl, } from "./raydium_amm.js"; @@ -99,14 +101,16 @@ import { } from "../grpc/program_ids.js"; const DLMM_DISC = { - SWAP: DISC.RAYDIUM_CPMM_SWAP_BASE_IN, - ADD_LIQUIDITY: u64leDiscriminator([181, 157, 89, 67, 143, 182, 52, 72]), - REMOVE_LIQUIDITY: u64leDiscriminator([80, 85, 209, 72, 24, 206, 35, 178]), - INITIALIZE_POOL: u64leDiscriminator([95, 180, 10, 172, 84, 174, 232, 40]), + SWAP: u64leDiscriminator([81, 108, 227, 190, 205, 208, 10, 196]), + SWAP2: u64leDiscriminator([46, 116, 82, 215, 148, 27, 84, 77]), + ADD_LIQUIDITY: u64leDiscriminator([31, 94, 125, 90, 227, 52, 61, 186]), + REMOVE_LIQUIDITY: u64leDiscriminator([116, 244, 97, 232, 103, 31, 152, 58]), + INITIALIZE_POOL: u64leDiscriminator([185, 74, 252, 125, 27, 215, 188, 111]), INITIALIZE_BIN_ARRAY: u64leDiscriminator([11, 18, 155, 194, 33, 115, 238, 119]), - CREATE_POSITION: u64leDiscriminator([123, 233, 11, 43, 146, 180, 97, 119]), - CLOSE_POSITION: u64leDiscriminator([94, 168, 102, 45, 59, 122, 137, 54]), - CLAIM_FEE: u64leDiscriminator([152, 70, 208, 111, 104, 91, 44, 1]), + CREATE_POSITION: u64leDiscriminator([144, 142, 252, 84, 157, 53, 37, 121]), + CLOSE_POSITION: u64leDiscriminator([255, 196, 16, 107, 28, 202, 53, 128]), + CLAIM_FEE: u64leDiscriminator([75, 122, 154, 48, 140, 74, 123, 163]), + CLAIM_FEE2: u64leDiscriminator([232, 171, 242, 97, 58, 77, 35, 45]), } as const; function discriminatorToEventType(disc: bigint): EventType | null { @@ -229,7 +233,11 @@ function programScopedDiscriminatorToEventType(programId: string | undefined, di return null; } if (programId === RAYDIUM_CPMM_PROGRAM_ID) { - if (disc === DISC.RAYDIUM_CPMM_SWAP_BASE_IN || disc === DISC.RAYDIUM_CPMM_SWAP_BASE_OUT) return "RaydiumCpmmSwap"; + if ( + disc === DISC.RAYDIUM_CPMM_SWAP_EVENT || + disc === DISC.RAYDIUM_CPMM_SWAP_BASE_IN || + disc === DISC.RAYDIUM_CPMM_SWAP_BASE_OUT + ) return "RaydiumCpmmSwap"; if (disc === DISC.RAYDIUM_CPMM_CREATE_POOL) return "RaydiumCpmmInitialize"; if (disc === DISC.RAYDIUM_CPMM_DEPOSIT) return "RaydiumCpmmDeposit"; if (disc === DISC.RAYDIUM_CPMM_WITHDRAW) return "RaydiumCpmmWithdraw"; @@ -275,14 +283,14 @@ function programScopedDiscriminatorToEventType(programId: string | undefined, di return null; } if (programId === METEORA_DLMM_PROGRAM_ID) { - if (disc === DLMM_DISC.SWAP) return "MeteoraDlmmSwap"; + if (disc === DLMM_DISC.SWAP || disc === DLMM_DISC.SWAP2) return "MeteoraDlmmSwap"; if (disc === DLMM_DISC.ADD_LIQUIDITY) return "MeteoraDlmmAddLiquidity"; if (disc === DLMM_DISC.REMOVE_LIQUIDITY) return "MeteoraDlmmRemoveLiquidity"; if (disc === DLMM_DISC.INITIALIZE_POOL) return "MeteoraDlmmInitializePool"; if (disc === DLMM_DISC.INITIALIZE_BIN_ARRAY) return "MeteoraDlmmInitializeBinArray"; if (disc === DLMM_DISC.CREATE_POSITION) return "MeteoraDlmmCreatePosition"; if (disc === DLMM_DISC.CLOSE_POSITION) return "MeteoraDlmmClosePosition"; - if (disc === DLMM_DISC.CLAIM_FEE) return "MeteoraDlmmClaimFee"; + if (disc === DLMM_DISC.CLAIM_FEE || disc === DLMM_DISC.CLAIM_FEE2) return "MeteoraDlmmClaimFee"; return null; } return discriminatorToEventType(disc); @@ -535,6 +543,14 @@ export function parseLogOptimized( recentBlockhash?: Uint8Array, programId?: string ): DexEvent | null { + if (programId === RAYDIUM_AMM_V4_PROGRAM_ID && log.indexOf("ray_log: ") >= 0) { + if (eventTypeFilter && !eventTypeFilter.shouldInclude("RaydiumAmmV4Swap")) return null; + const rb = recentBlockhash && recentBlockhash.length > 0 + ? bs58.encode(recentBlockhash) + : undefined; + const metadata = makeMetadata(signature, slot, txIndex, blockTimeUs, grpcRecvUs, rb); + return parseAmmRayLogSwap(log, metadata); + } const buf = decodeProgramDataLine(log); if (!buf) return null; const disc = readDiscriminatorU64(buf); @@ -601,6 +617,8 @@ export function parseLogOptimized( } if (programId === RAYDIUM_CPMM_PROGRAM_ID) { switch (disc) { + case DISC.RAYDIUM_CPMM_SWAP_EVENT: + return applyActualEventTypeFilter(parseCpmmSwapEvent(data, metadata), eventTypeFilter); case DISC.RAYDIUM_CPMM_SWAP_BASE_IN: return applyActualEventTypeFilter(parseCpmmSwapIn(data, metadata), eventTypeFilter); case DISC.RAYDIUM_CPMM_SWAP_BASE_OUT: diff --git a/src/logs/program_log_discriminators.ts b/src/logs/program_log_discriminators.ts index 3bd2d0a..2aa5f76 100644 --- a/src/logs/program_log_discriminators.ts +++ b/src/logs/program_log_discriminators.ts @@ -54,6 +54,8 @@ export const PROGRAM_LOG_DISC = { RAYDIUM_CLMM_CREATE_POOL: u64leDiscriminator([25, 94, 75, 47, 112, 99, 53, 63]), RAYDIUM_CLMM_COLLECT_PERSONAL_FEE: u64leDiscriminator([166, 174, 105, 192, 81, 161, 83, 105]), RAYDIUM_CLMM_COLLECT_PROTOCOL_FEE: u64leDiscriminator([206, 87, 17, 79, 45, 41, 213, 61]), + // Anchor event: `event:SwapEvent` (shared with CLMM; program context disambiguates it). + RAYDIUM_CPMM_SWAP_EVENT: u64leDiscriminator([64, 198, 205, 232, 38, 8, 113, 226]), RAYDIUM_CPMM_SWAP_BASE_IN: u64leDiscriminator([143, 190, 90, 218, 196, 30, 51, 222]), RAYDIUM_CPMM_SWAP_BASE_OUT: u64leDiscriminator([55, 217, 98, 86, 163, 74, 180, 173]), RAYDIUM_CPMM_CREATE_POOL: u64leDiscriminator([233, 146, 209, 142, 207, 104, 64, 188]), diff --git a/src/logs/raydium_amm.ts b/src/logs/raydium_amm.ts index 0e2a723..a460806 100644 --- a/src/logs/raydium_amm.ts +++ b/src/logs/raydium_amm.ts @@ -41,6 +41,39 @@ function emptySwap(metadata: EventMetadata, amm: string, user: string): RaydiumA }; } +const RAY_LOG_PREFIX = "Program log: ray_log: "; + +/** Decode Raydium AMM V4's official bincode `SwapBaseInLog` / `SwapBaseOutLog`. */ +export function parseRayLogSwap(log: string, metadata: EventMetadata): DexEvent | null { + const prefix = log.indexOf(RAY_LOG_PREFIX); + if (prefix < 0) return null; + const encoded = log.slice(prefix + RAY_LOG_PREFIX.length).trim(); + let data: Uint8Array; + try { + data = Buffer.from(encoded, "base64"); + } catch { + return null; + } + // Both swap bincode structs are one u8 followed by seven u64 values. + if (data.length !== 57 || (data[0] !== 3 && data[0] !== 4)) return null; + const input = readU64LE(data, 1); + const output = readU64LE(data, 9); + const actual = readU64LE(data, 49); + if (input == null || output == null || actual == null) return null; + + const ev = emptySwap(metadata, defaultPubkey(), defaultPubkey()); + if (data[0] === 3) { + ev.amount_in = input; + ev.minimum_amount_out = output; + ev.amount_out = actual; + } else { + ev.max_amount_in = input; + ev.amount_out = output; + ev.amount_in = actual; + } + return { RaydiumAmmV4Swap: ev }; +} + export function parseSwapBaseInFromData(data: Uint8Array, metadata: EventMetadata): DexEvent | null { let o = 0; const amm = readPubkey(data, o); diff --git a/src/logs/raydium_cpmm.ts b/src/logs/raydium_cpmm.ts index 3ba39a9..aba4d85 100644 --- a/src/logs/raydium_cpmm.ts +++ b/src/logs/raydium_cpmm.ts @@ -12,6 +12,49 @@ function bn64(v: ReturnType): bigint { return v ?? 0n; } +/** Current Anchor `SwapEvent` payload (the 8-byte event discriminator is removed by the caller). */ +export function parseSwapEventFromData(data: Uint8Array, metadata: EventMetadata): DexEvent | null { + // pool_id + six u64 fields + base_input. Newer IDLs append mint/fee fields, + // which remain wire-compatible with this stable prefix. + if (data.length < 32 + (6 * 8) + 1) return null; + let o = 0; + const pool_id = readPubkey(data, o); + if (!pool_id) return null; + o += 32; + const input_vault_before = readU64LE(data, o); + o += 8; + const output_vault_before = readU64LE(data, o); + o += 8; + const input_amount = readU64LE(data, o); + o += 8; + const output_amount = readU64LE(data, o); + o += 8; + const input_transfer_fee = readU64LE(data, o); + o += 8; + const output_transfer_fee = readU64LE(data, o); + o += 8; + const base_input = readBool(data, o); + if ( + input_vault_before == null || output_vault_before == null || + input_amount == null || output_amount == null || + input_transfer_fee == null || output_transfer_fee == null || + base_input == null + ) return null; + + const ev: RaydiumCpmmSwapEvent = { + metadata, + pool_id, + input_vault_before, + output_vault_before, + input_amount, + output_amount, + input_transfer_fee, + output_transfer_fee, + base_input, + }; + return { RaydiumCpmmSwap: ev }; +} + export function parseSwapBaseInFromData(data: Uint8Array, metadata: EventMetadata): DexEvent | null { let o = 0; const pool_state = readPubkey(data, o)!; diff --git a/src/rpc_transaction.test.ts b/src/rpc_transaction.test.ts index c5821f4..8539371 100644 --- a/src/rpc_transaction.test.ts +++ b/src/rpc_transaction.test.ts @@ -1,12 +1,16 @@ import { PublicKey, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js"; import { describe, expect, it } from "vitest"; import { PUMPFUN_PROGRAM_ID } from "./grpc/program_ids.js"; +import { METEORA_DLMM_PROGRAM_ID } from "./instr/program_ids.js"; import { parseRpcTransaction } from "./rpc_transaction.js"; const PUMPFUN_BUY = [102, 6, 61, 18, 1, 218, 235, 234] as const; const PUMPFUN_TRADE = [189, 219, 127, 211, 78, 230, 97, 238] as const; const EVENT_CPI_SUFFIX = [155, 167, 108, 32, 122, 76, 173, 64] as const; const PUMPFUN_CREATE_PREFIX = "Program data: G3KpTd7rY3Y"; +const DLMM_SWAP = [248, 198, 158, 145, 225, 117, 135, 200] as const; +const DLMM_SWAP2_EVENT = [46, 116, 82, 215, 148, 27, 84, 77] as const; +const ANCHOR_EVENT_CPI = [228, 69, 165, 46, 81, 203, 154, 29] as const; function pk(seed: number): PublicKey { return new PublicKey(Uint8Array.from({ length: 32 }, (_, i) => (seed + i) & 0xff)); @@ -72,6 +76,23 @@ function outerBuyIxData(amount: bigint, maxSolCost: bigint): Uint8Array { return Uint8Array.from(out); } +function dlmmSwapIxData(amountIn: bigint, minOut: bigint): Uint8Array { + const out = [...DLMM_SWAP]; + pushU64(out, amountIn); + pushU64(out, minOut); + return Uint8Array.from(out); +} + +function dlmmSwap2EventData(amountIn: bigint, amountOut: bigint): Uint8Array { + const data = new Uint8Array(16 + 147); + data.set(ANCHOR_EVENT_CPI, 0); + data.set(DLMM_SWAP2_EVENT, 8); + const view = new DataView(data.buffer); + view.setBigUint64(16 + 89, amountIn, true); + view.setBigUint64(16 + 105, amountOut, true); + return data; +} + function pumpfunTradeLog(ixName: string): string { return `Program data: ${Buffer.from(Uint8Array.from([...PUMPFUN_TRADE, ...pumpfunTradePayload(ixName)])).toString("base64")}`; } @@ -151,4 +172,111 @@ describe("parseRpcTransaction parity", () => { expect("PumpFunBuy" in events[0]!).toBe(true); expect((events[0] as any).PumpFunBuy.is_created_buy).toBe(true); }); + + it("merges each aggregator DLMM swap with its direct event CPI", () => { + const dlmmProgram = new PublicKey(METEORA_DLMM_PROGRAM_ID); + const aggregatorProgram = pk(210); + const dlmmAccounts = Array.from({ length: 11 }, (_, i) => ({ + pubkey: pk(20 + i), + isSigner: false, + isWritable: true, + })); + const message = new TransactionMessage({ + payerKey: pk(240), + recentBlockhash: "11111111111111111111111111111111", + instructions: [new TransactionInstruction({ + programId: aggregatorProgram, + keys: [{ pubkey: dlmmProgram, isSigner: false, isWritable: false }, ...dlmmAccounts], + data: Buffer.alloc(0), + })], + }).compileToV0Message(); + const dlmmProgramIndex = message.staticAccountKeys.findIndex((key) => key.equals(dlmmProgram)); + const accountIndexes = dlmmAccounts.map(({ pubkey }) => + message.staticAccountKeys.findIndex((key) => key.equals(pubkey)) + ); + const innerInstructions = [ + { data: dlmmSwapIxData(1n, 1n), stackHeight: 2 }, + { data: dlmmSwap2EventData(10n, 9n), stackHeight: 3 }, + { data: dlmmSwapIxData(2n, 2n), stackHeight: 2 }, + { data: dlmmSwap2EventData(20n, 18n), stackHeight: 3 }, + ].map(({ data, stackHeight }, index) => ({ + programIdIndex: dlmmProgramIndex, + accounts: index % 2 === 0 ? accountIndexes : [], + data, + stackHeight, + })); + const tx = { + slot: 7, + blockTime: null, + meta: { + fee: 0, + preBalances: [], + postBalances: [], + logMessages: [], + innerInstructions: [{ index: 0, instructions: innerInstructions }], + preTokenBalances: [], + postTokenBalances: [], + err: null, + }, + transaction: new VersionedTransaction(message), + } as any; + + const parsed = parseRpcTransaction(tx, "sig", undefined, { grpcRecvUs: 99 }); + expect(parsed.ok).toBe(true); + const events = parsed.ok ? parsed.events : []; + expect(events).toHaveLength(2); + expect(events.map((event) => (event as any).MeteoraDlmmSwap.amount_in)) + .toEqual([10n, 20n]); + expect(events.map((event) => (event as any).MeteoraDlmmSwap.amount_out)) + .toEqual([9n, 18n]); + }); + + it("keeps DLMM position fields that are absent from PositionCreate event CPI", () => { + const dlmmProgram = new PublicKey(METEORA_DLMM_PROGRAM_ID); + const accounts = Array.from({ length: 5 }, (_, i) => ({ + pubkey: pk(80 + i), isSigner: false, isWritable: true, + })); + const positionIx = new Uint8Array(16); + positionIx.set([219, 192, 234, 71, 190, 191, 102, 80]); + new DataView(positionIx.buffer).setInt32(8, -42, true); + new DataView(positionIx.buffer).setInt32(12, 70, true); + const positionEvent = new Uint8Array(16 + 96); + positionEvent.set(ANCHOR_EVENT_CPI, 0); + positionEvent.set([144, 142, 252, 84, 157, 53, 37, 121], 8); + positionEvent.set(accounts[2]!.pubkey.toBytes(), 16); + positionEvent.set(accounts[1]!.pubkey.toBytes(), 48); + positionEvent.set(accounts[3]!.pubkey.toBytes(), 80); + const message = new TransactionMessage({ + payerKey: pk(240), + recentBlockhash: "11111111111111111111111111111111", + instructions: [new TransactionInstruction({ + programId: dlmmProgram, keys: accounts, data: Buffer.from(positionIx), + })], + }).compileToV0Message(); + const outer = message.compiledInstructions[0]!; + const tx = { + slot: 7, + blockTime: null, + meta: { + fee: 0, preBalances: [], postBalances: [], logMessages: [], + innerInstructions: [{ + index: 0, + instructions: [{ + programIdIndex: outer.programIdIndex, + accounts: [], + data: positionEvent, + stackHeight: 2, + }], + }], + preTokenBalances: [], postTokenBalances: [], err: null, + }, + transaction: new VersionedTransaction(message), + } as any; + + const parsed = parseRpcTransaction(tx, "sig", undefined, { grpcRecvUs: 99 }); + expect(parsed.ok).toBe(true); + const event = parsed.ok ? (parsed.events[0] as any).MeteoraDlmmCreatePosition : null; + expect(event?.lower_bin_id).toBe(-42); + expect(event?.width).toBe(70); + }); }); diff --git a/src/rpc_transaction.ts b/src/rpc_transaction.ts index 8178625..d223995 100644 --- a/src/rpc_transaction.ts +++ b/src/rpc_transaction.ts @@ -30,6 +30,7 @@ import { parseInnerInstructionUnified, } from "./instr/inner.js"; import { parseInstructionUnified } from "./instr/mod.js"; +import { METEORA_DLMM_PROGRAM_ID } from "./instr/program_ids.js"; import { parseInvokeInfo, parseLogOptimizedWithProgramId, @@ -37,7 +38,40 @@ import { } from "./logs/optimized_matcher.js"; const DEFAULT_PK = PublicKey.default.toBase58(); -type IndexedInstructionEvent = { outerIdx: number; innerIdx: number | null; event: DexEvent }; +const PUMPFUN_TRADE_EVENT_NAMES = new Set([ + "PumpFunTrade", + "PumpFunBuy", + "PumpFunSell", + "PumpFunBuyExactSolIn", +]); +type IndexedInstructionEvent = { + outerIdx: number; + innerIdx: number | null; + stackHeight?: number; + isDlmmEventCpi: boolean; + event: DexEvent; +}; + +const EVENT_CPI_PREFIX = Uint8Array.from([228, 69, 165, 46, 81, 203, 154, 29]); +const LEGACY_EVENT_CPI_SUFFIX = Uint8Array.from([155, 167, 108, 32, 122, 76, 173, 64]); + +function bytesEqualAt(data: Uint8Array, expected: Uint8Array, offset: number): boolean { + for (let i = 0; i < expected.length; i++) { + if (data[offset + i] !== expected[i]) return false; + } + return true; +} + +function isDlmmEventCpi(programId: string, data: Uint8Array): boolean { + return programId === METEORA_DLMM_PROGRAM_ID && data.length >= 16 && ( + bytesEqualAt(data, EVENT_CPI_PREFIX, 0) || + bytesEqualAt(data, LEGACY_EVENT_CPI_SUFFIX, 8) + ); +} + +function isDlmmEvent(event: DexEvent): boolean { + return eventName(event).startsWith("MeteoraDlmm"); +} function eventName(ev: DexEvent): string { return Object.keys(ev)[0] ?? ""; @@ -247,27 +281,33 @@ function mergePumpSwapBuySellInstruction(base: Record, inner: Recor } } -function mergeInstructionEvent(base: DexEvent, inner: DexEvent): void { +function mergeDlmmInstruction(baseName: string, base: Record, inner: Record): void { + const context = baseName === "MeteoraDlmmInitializePool" + ? { creator: base.creator, active_bin_id: base.active_bin_id } + : baseName === "MeteoraDlmmCreatePosition" + ? { lower_bin_id: base.lower_bin_id, width: base.width } + : baseName === "MeteoraDlmmClosePosition" + ? { pool: base.pool } + : null; + Object.assign(base, inner); + if (context) Object.assign(base, context); +} + +function mergeInstructionEvent(base: DexEvent, inner: DexEvent): boolean { const baseName = eventName(base); const innerName = eventName(inner); const baseData = eventPayload(base); const innerData = eventPayload(inner); - if (!baseData || !innerData) return; - - const pumpfunTradeNames = new Set([ - "PumpFunTrade", - "PumpFunBuy", - "PumpFunSell", - "PumpFunBuyExactSolIn", - ]); - if (pumpfunTradeNames.has(baseName) && pumpfunTradeNames.has(innerName)) { + if (!baseData || !innerData) return false; + + if (PUMPFUN_TRADE_EVENT_NAMES.has(baseName) && PUMPFUN_TRADE_EVENT_NAMES.has(innerName)) { mergePumpfunTradeInstruction(baseData, innerData); - return; + return true; } if (baseName === "PumpFunCreateV2" && innerName === "PumpFunCreateV2") { mergePumpfunCreateV2Instruction(baseData, innerData); - return; + return true; } if ( @@ -275,12 +315,18 @@ function mergeInstructionEvent(base: DexEvent, inner: DexEvent): void { (baseName === "PumpSwapSell" && innerName === "PumpSwapSell") ) { mergePumpSwapBuySellInstruction(baseData, innerData); - return; + return true; } if (baseName === innerName) { - Object.assign(baseData, innerData); + if (baseName.startsWith("MeteoraDlmm")) { + mergeDlmmInstruction(baseName, baseData, innerData); + } else { + Object.assign(baseData, innerData); + } + return true; } + return false; } function mergeInstructionEvents(events: IndexedInstructionEvent[]): DexEvent[] { @@ -293,24 +339,74 @@ function mergeInstructionEvents(events: IndexedInstructionEvent[]): DexEvent[] { }); const out: DexEvent[] = []; - let pending: { outerIdx: number; event: DexEvent } | null = null; + let outerTarget: { outerIdx: number; resultIdx: number } | null = null; + const dlmmTargets: Array<{ + outerIdx: number; + stackHeight?: number; + resultIdx: number; + }> = []; for (const item of events) { if (item.innerIdx === null) { - if (pending) out.push(pending.event); - pending = { outerIdx: item.outerIdx, event: item.event }; + out.push(item.event); + const resultIdx = out.length - 1; + outerTarget = { outerIdx: item.outerIdx, resultIdx }; + dlmmTargets.length = 0; + if (isDlmmEvent(item.event)) { + dlmmTargets.push({ + outerIdx: item.outerIdx, + stackHeight: item.stackHeight, + resultIdx, + }); + } continue; } - if (pending && pending.outerIdx === item.outerIdx) { - mergeInstructionEvent(pending.event, item.event); - } else { - if (pending) { - out.push(pending.event); - pending = null; + + if (item.isDlmmEventCpi) { + let merged = false; + for (let i = dlmmTargets.length - 1; i >= 0; i--) { + const target = dlmmTargets[i]!; + const directChild = target.stackHeight === undefined || item.stackHeight === undefined || + item.stackHeight === target.stackHeight + 1; + if (target.outerIdx === item.outerIdx && directChild) { + dlmmTargets.length = i + 1; + merged = mergeInstructionEvent(out[target.resultIdx]!, item.event); + break; + } } + if (!merged) out.push(item.event); + continue; + } + + let resultIdx = -1; + if (outerTarget && outerTarget.outerIdx === item.outerIdx && + mergeInstructionEvent(out[outerTarget.resultIdx]!, item.event)) { + resultIdx = outerTarget.resultIdx; + } else { out.push(item.event); + resultIdx = out.length - 1; + } + + if (isDlmmEvent(item.event)) { + if (item.stackHeight === undefined) { + dlmmTargets.length = 0; + } else { + while (dlmmTargets.length > 0) { + const last = dlmmTargets[dlmmTargets.length - 1]!; + if (last.outerIdx !== item.outerIdx || + (last.stackHeight !== undefined && last.stackHeight >= item.stackHeight)) { + dlmmTargets.pop(); + } else { + break; + } + } + } + dlmmTargets.push({ + outerIdx: item.outerIdx, + stackHeight: item.stackHeight, + resultIdx, + }); } } - if (pending) out.push(pending.event); return out; } @@ -352,7 +448,13 @@ function parseOuterAndInnerInstructions( filter, programId ); - if (ev) indexedEvents.push({ outerIdx, innerIdx: null, event: ev }); + if (ev) indexedEvents.push({ + outerIdx, + innerIdx: null, + stackHeight: 1, + isDlmmEventCpi: false, + event: ev, + }); } const innerGroups = meta?.innerInstructions; @@ -388,7 +490,13 @@ function parseOuterAndInnerInstructions( programId, isCreatedBuy ); - if (ev) indexedEvents.push({ outerIdx: group.index, innerIdx, event: ev }); + if (ev) indexedEvents.push({ + outerIdx: group.index, + innerIdx, + stackHeight: (ix as CompiledInstruction & { stackHeight?: number }).stackHeight, + isDlmmEventCpi: isDlmmEventCpi(programId, data), + event: ev, + }); } }