import { BorshInstructionCoder } from '@coral-xyz/anchor' import { IDL } from '@meteora-ag/dlmm' import { MessageCompiledInstruction, PublicKey, TransactionResponse, VersionedTransactionResponse, } from '@solana/web3.js' import bs58 from 'bs58' import BN from 'bn.js' import { ADD_LIQUIDITY_NAMES, CLOSE_POSITION_NAMES, DLMM_PROGRAM_ID, INIT_POSITION_NAMES, REBALANCE_NAMES, REMOVE_LIQUIDITY_NAMES, } from '../constants' import type { DlmmActionType, ParsedDlmmOperation } from '../meteora/types' // Instruction name mapping: Anchor IDL uses snake_case, we use camelCase internally const INSTRUCTION_NAME_MAP: Record = { add_liquidity: 'addLiquidity', add_liquidity_by_strategy: 'addLiquidityByStrategy', add_liquidity_by_strategy2: 'addLiquidityByStrategy2', add_liquidity_by_strategy_one_side: 'addLiquidityByStrategyOneSide', remove_liquidity: 'removeLiquidity', remove_liquidity_by_range: 'removeLiquidityByRange', remove_liquidity_by_range2: 'removeLiquidityByRange2', close_position: 'closePosition', close_position2: 'closePosition2', close_position_if_empty: 'closePositionIfEmpty', initialize_position: 'initializePosition', initialize_position2: 'initializePosition2', rebalance_liquidity: 'rebalanceLiquidity', } // Account index mapping for each instruction (snake_case IDL names) // Verified against IDL account layouts const ACCOUNT_MAPS: Record< string, { position: number; lbPair: number; sender: number } > = { // Add liquidity add_liquidity: { position: 0, lbPair: 1, sender: 11 }, add_liquidity_by_strategy: { position: 0, lbPair: 1, sender: 11 }, add_liquidity_by_strategy2: { position: 0, lbPair: 1, sender: 9 }, add_liquidity_by_strategy_one_side: { position: 0, lbPair: 1, sender: 8 }, // Remove liquidity remove_liquidity: { position: 0, lbPair: 1, sender: 11 }, remove_liquidity_by_range: { position: 0, lbPair: 1, sender: 11 }, remove_liquidity_by_range2: { position: 0, lbPair: 1, sender: 9 }, // Close position close_position: { position: 0, lbPair: 1, sender: 4 }, close_position2: { position: 0, lbPair: -1, sender: 1 }, // close_position_if_empty: accounts = [position, sender, rent_receiver, event_authority, program] close_position_if_empty: { position: 0, lbPair: -1, sender: 1 }, // Initialize position initialize_position: { position: 1, lbPair: 2, sender: 0 }, initialize_position2: { position: 1, lbPair: 2, sender: 0 }, // Rebalance liquidity // IDL: [position, lb_pair, bin_array_bitmap_extension, user_token_x, user_token_y, // reserve_x, reserve_y, token_x_mint, token_y_mint, owner, ...] rebalance_liquidity: { position: 0, lbPair: 1, sender: 9 }, } // eslint-disable-next-line @typescript-eslint/no-explicit-any const coder = new BorshInstructionCoder(IDL as any) function classifyAction(name: string): DlmmActionType | null { const camelName = INSTRUCTION_NAME_MAP[name] ?? name if (ADD_LIQUIDITY_NAMES.has(camelName)) return 'ADD_LIQUIDITY' if (REMOVE_LIQUIDITY_NAMES.has(camelName)) return 'REMOVE_LIQUIDITY' if (CLOSE_POSITION_NAMES.has(camelName)) return 'CLOSE_POSITION' if (INIT_POSITION_NAMES.has(camelName)) return 'INIT_POSITION' if (REBALANCE_NAMES.has(camelName)) return 'REBALANCE_LIQUIDITY' return null } function resolveAllAccountKeys( tx: VersionedTransactionResponse | TransactionResponse ): PublicKey[] { const msg = tx.transaction.message // Versioned (v0) transactions if ('getAccountKeys' in msg) { // Must pass loaded addresses from lookup tables to avoid // "address table lookups were not resolved" error on v0 txs const toKey = (k: string | PublicKey) => typeof k === 'string' ? new PublicKey(k) : k const accountKeysFromLookups = tx.meta?.loadedAddresses ? { writable: tx.meta.loadedAddresses.writable.map(toKey), readonly: tx.meta.loadedAddresses.readonly.map(toKey), } : undefined const keys = ( msg as { getAccountKeys: (opts: object) => { length: number get: (i: number) => PublicKey | undefined } } ).getAccountKeys({ accountKeysFromLookups }) const allKeys: PublicKey[] = [] for (let i = 0; i < keys.length; i++) { const key = keys.get(i) if (key) allKeys.push(key) } return allKeys } // Legacy transactions if ('accountKeys' in msg) { return (msg as { accountKeys: PublicKey[] }).accountKeys } return [] } interface ParsedInstruction { programIdIndex: number accounts: number[] dataBuffer: Buffer } function extractInstructions( tx: VersionedTransactionResponse | TransactionResponse ): ParsedInstruction[] { const msg = tx.transaction.message if ('compiledInstructions' in msg) { // Versioned transaction: data is Uint8Array return ( msg as { compiledInstructions: MessageCompiledInstruction[] } ).compiledInstructions.map((ix) => ({ programIdIndex: ix.programIdIndex, accounts: [...ix.accountKeyIndexes], dataBuffer: Buffer.from(ix.data), })) } if ('instructions' in msg) { // Legacy transaction: data is base58 encoded string return ( msg as { instructions: { programIdIndex: number accounts: number[] data: string }[] } ).instructions.map((ix) => ({ programIdIndex: ix.programIdIndex, accounts: ix.accounts, dataBuffer: Buffer.from(bs58.decode(ix.data)), })) } return [] } export function parseTransaction( tx: VersionedTransactionResponse | TransactionResponse ): ParsedDlmmOperation[] { if (!tx.meta || tx.meta.err) return [] const accountKeys = resolveAllAccountKeys(tx) const instructions = extractInstructions(tx) const operations: ParsedDlmmOperation[] = [] for (const ix of instructions) { const programId = accountKeys[ix.programIdIndex] if (!programId || !programId.equals(DLMM_PROGRAM_ID)) continue try { const decoded = coder.decode(ix.dataBuffer) if (!decoded) continue const action = classifyAction(decoded.name) if (!action) continue const accountMap = ACCOUNT_MAPS[decoded.name] if (!accountMap) continue const positionAddress = accountKeys[ix.accounts[accountMap.position]] const lbPairAddress = accountMap.lbPair >= 0 ? accountKeys[ix.accounts[accountMap.lbPair]] : undefined const senderAddress = accountKeys[ix.accounts[accountMap.sender]] if (!positionAddress || !senderAddress) continue const op: ParsedDlmmOperation = { action, instructionName: INSTRUCTION_NAME_MAP[decoded.name] ?? decoded.name, positionAddress, lbPairAddress: lbPairAddress ?? positionAddress, senderAddress, } // Extract add liquidity parameters if (action === 'ADD_LIQUIDITY') { const data = decoded.data as Record const liqParam = data.liquidity_parameter as | Record | undefined if (liqParam) { op.totalXAmount = new BN(liqParam.amount_x?.toString() ?? '0') op.totalYAmount = new BN(liqParam.amount_y?.toString() ?? '0') const strategyParams = liqParam.strategy_parameters as | Record | undefined if (strategyParams) { op.minBinId = strategyParams.min_bin_id as number op.maxBinId = strategyParams.max_bin_id as number const strategyType = strategyParams.strategy_type as | Record | undefined if (strategyType) { const strategyName = Object.keys(strategyType)[0] const strategyTypes = [ 'SpotOneSide', 'CurveOneSide', 'BidAskOneSide', 'SpotBalanced', 'CurveBalanced', 'BidAskBalanced', 'SpotImBalanced', 'CurveImBalanced', 'BidAskImBalanced', ] op.strategyType = strategyTypes.indexOf(strategyName) } } } } // Extract remove liquidity parameters if (action === 'REMOVE_LIQUIDITY') { const data = decoded.data as Record if (data.from_bin_id !== undefined) { op.fromBinId = data.from_bin_id as number op.toBinId = data.to_bin_id as number op.bpsToRemove = data.bps_to_remove as number } } // Extract rebalance liquidity parameters // IDL args: { params: RebalanceLiquidityParams, remaining_accounts_info: ... } if (action === 'REBALANCE_LIQUIDITY') { const data = decoded.data as Record // The IDL arg name is "params", not "rebalance_liquidity_params" const rebalanceParams = data.params as | Record | undefined if (rebalanceParams) { op.rebalanceActiveId = rebalanceParams.active_id as number const adds = rebalanceParams.adds as | Record[] | undefined if (adds && adds.length > 0) { op.rebalanceAdds = adds.map((add) => ({ minDeltaId: add.min_delta_id as number, maxDeltaId: add.max_delta_id as number, })) // Compute absolute bin IDs from deltas for downstream use const activeId = op.rebalanceActiveId ?? 0 const allMinBins = adds.map( (a) => activeId + (a.min_delta_id as number) ) const allMaxBins = adds.map( (a) => activeId + (a.max_delta_id as number) ) op.minBinId = Math.min(...allMinBins) op.maxBinId = Math.max(...allMaxBins) } const removes = rebalanceParams.removes as | Record[] | undefined if (removes && removes.length > 0) { op.rebalanceRemoves = removes.map((rem) => ({ fromBinId: rem.from_bin_id as number, toBinId: rem.to_bin_id as number, bpsToRemove: rem.bps_to_remove as number, })) } // Note: max_deposit_x_amount / max_deposit_y_amount are safety caps // (often set to u64::MAX), NOT actual deposit amounts. Do not use // them as totalXAmount/totalYAmount — the rebalance executor // queries the follower's actual token balances instead. } else { console.warn( `[Parser] Could not extract rebalance params. Keys: ${Object.keys(data).join(', ')}` ) } } // Extract init position parameters if (action === 'INIT_POSITION') { const data = decoded.data as Record if (data.lower_bin_id !== undefined) { op.minBinId = data.lower_bin_id as number const width = data.width as number if (width) { op.maxBinId = (data.lower_bin_id as number) + width - 1 } } } operations.push(op) } catch { // Skip instructions that fail to decode continue } } return operations }